0

I'm facing an issue implementing The Swift Composable Architecture in which I have a list of IdentifiedArray rows within my AppState that holds RowState which holds an EnumRowState as part of it's state, to allow me to SwitchStore on it within a RowView that is rendered within a ForEachStore on the AppView.

The problem I'm running into is that within a child reducer called liveReducer, there is an Effect.timer that is updating every one second, and should only be causing the LiveView to re-render.

However what's happening is that the AddView is also getting re-rendered every time too! The reason I know this is because I've manually added a Text("\(Date())") within the AddView and I see the date changing every one second regardless of the fact that it's not related in anyway to a change in state.

I've added .debug() to the appReducer and I see in the logs that the rows part of the sate shows ...(1 unchanged) which sounds right, so why then is every single row being re-rendered on every single Effect.timer effect?

Thank you in advance!

Below is how I've implemented this:

P.S. I've used a technique as describe here to pullback reducers on an enum property: https://forums.swift.org/t/pullback-reducer-on-enum-property-switchstore/52628

Here is a video of the problem, in which you can see that once I've selected a name from the menu, ALL the views are getting updated, INCLUDING the navBarTitle!

https://youtube.com/shorts/rn_Yd57n1r8

struct AppState: Equatable {
  var rows: IdentifiedArray<UUID, RowState> = []
}

enum AppAction: Equatable {
  case row(id: UUID, action: RowAction)
}

public struct RowState: Equatable, Identifiable {
  public var enumRowState: EnumRowState
  public let id: UUID
}

public enum EnumRowState: Equatable {
  case add(AddState)
  case live(LiveState)
}

public enum RowAction: Equatable {
  case live(AddAction)
  case add(LiveAction)
}

public struct LiveState: Equatable {
  public var secondsElapsed = 0
}

enum LiveAction: Equatable {
  case onAppear
  case onDisappear
  case timerTicked
}

struct AppState: Equatable { }

enum AddAction: Equatable { }

public let liveReducer = Reducer<LiveState, LiveAction, LiveEnvironment>.init({
  state, action, environment in
  switch action {
  case .onAppear:
    return Effect
      .timer(
        id: state.baby.uid,
        every: 1,
        tolerance: .zero,
        on: environment.mainQueue)
      .map { _ in
        LiveAction.timerTicked
      })
 
 case .timerTicked:
    state.secondsElapsed += 1
    return .none
    
  case .onDisappear:
    return .cancel(id: state.baby.uid)
  }
})

public let addReducer = Reducer<AddState, AddAction, AddEnvironment>.init({
  state, action, environment in
  switch action {
  })
}

///
/// Intermediate reducers to pull back to an Enum State which will be used within the `SwitchStore`
///

public var intermediateAddReducer: Reducer<EnumRowState, RowAction, RowEnvironment> {
  return addReducer.pullback(
    state: /EnumRowState.add,
    action: /RowAction.add,
    environment: { ... }
  )
}

public var intermediateLiveReducer: Reducer<EnumRowState, RowAction, RowEnvironment > {
  return liveReducer.pullback(
    state: /EnumRowState.live,
    action: /RowAction.live,
    environment: { ...  }
  )
}

public let rowReducer: Reducer<RowState, RowAction, RowEnvironment> = .combine(
  intermediateAddReducer.pullback(
    state: \RowState.enumRowState,
    action: /RowAction.self,
    environment: { $0 }
  ),
  intermediateLiveReducer.pullback(
    state: \RowState.enumRowState,
    action: /RowAction.self,
    environment: { $0 }
  )
)

let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
  rowReducer.forEach(
    state: \.rows,
    action: /AppAction.row(id:action:),
    environment: { ...   }
  ),
  .init({ state, action, environment in
    switch action {
    case AppAction.onAppear:
      state.rows = [
          RowState(id: UUID(), enumRowState: .add(AddState()))
          RowState(id: UUID(), enumRowState: .add(LiveState()))
      ]
      return .none
    default:
      return .none
    }
  })
)
.debug()

public struct AppView: View {
  
  let store: Store<AppState, AppAction>
  
  public var body: some View {
      WithViewStore(self.store) { viewStore in
        List {
            ForEachStore(
              self.store.scope(
                state: \AppState.rows,
                action: AppAction.row(id:action:))
             ) { rowViewStore in
               RowView(store: rowViewStore)
             }
          }        
    }
}

public struct RowView: View {
  public let store: Store<RowState, RowAction>
  
  public var body: some View {
      WithViewStore(self.store) { viewStore in
        SwitchStore(self.store.scope(state: \RowState.enumRowState)) {
          CaseLet(state: /EnumRowState.add, action: RowAction.add) { store in
            AddView(store: store)
          }
          CaseLet(state: /EnumRowState.live, action: RowAction.live) { store in
            LiveView(store: store)
          }
        }
      }
  }
}

struct LiveView: View {
  let store: Store<LiveState, LiveAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      Text(viewStore.secondsElapsed)
    }
  }
}

struct AddView: View {
  let store: Store<AddState, AddAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      // This is getting re-rendered every time the `liveReducer`'s  `secondsElapsed` state changes!?!
      Text("\(Date())")
    }
  }
}

cohen72
  • 2,830
  • 29
  • 44
  • I have never really used this setup but all of SwiftUI's features are being ignored. If you watch Demystify SwiftUI from WWDC21 you will get a decent understanding of how SwiftUI keeps Views from rerendering using their identity. With this setup it seems like the entire app will be recreated every time there is the smallest of interactions, as you are experiencing. – lorem ipsum Jun 23 '22 at 13:38
  • @loremipsum sorry for not specifying, but I'm using the TCA - https://github.com/pointfreeco/swift-composable-architecture, which was built to supplement some of the features missing in SwiftUI. – cohen72 Jun 23 '22 at 14:21
  • Not a big deal it doesn't change my comment. This ignores everything that SwiftUI has built in to it to maximize resources, as you are experiencing. It would be a bad setup for a large app. I get the concept but it just isn't something that I think belongs with SwiftUI. – lorem ipsum Jun 23 '22 at 14:25
  • @loremipsum, thanks for your feedback, although not actually helping me in anyway with answering my question. If you have time, feel free to research more on the library. You can checkout: https://www.pointfree.co/collections/swiftui and https://www.pointfree.co/collections/composable-architecture for more explanation on why it's actually built for the purpose of modularizing large and complex apps, and actually leverages SwiftUI's framework, not ignoring it. – cohen72 Jun 23 '22 at 14:58
  • Ill give it another look sometime. My comment is more that this is an expected behavior vs a solution. It might make for a very adaptive app but it would be a poorly performing app since it isn't using any of the tools that help preserve identity so views don't get rendered as often. – lorem ipsum Jun 23 '22 at 15:18
  • 1
    Why not ask here https://github.com/pointfreeco/swift-composable-architecture/discussions ? – Fabio Felici Jun 23 '22 at 16:17

0 Answers0