Using Child SwitchStore Views within a Parent's ForEachStore View

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?

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!

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:

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())")
    }
  }
}

Hi @yehudacohen, in general, the problem is that your views are observing more state than needed. You can first scope your store before constructing the view store in order to chisel away the state to the bare essentials the view needs. We have lots of examples of this in our isowords application.

That's the problem in general. But in your code above things are even simpler. You are constructing view stores for views that don't actually need to observe any state. You can completely remove the WithViewStore from AppView and RowView and things will work the same. If there was such a thing as warnings for unused closure arguments you would see that the viewStore variable is never being used.

And the WithViewStore for LiveView can stay as it is because you are using all the data from LiveState in the view. If in the future you start adding more state to that feature to deal with internal logic that isn't used in the view, you could scope the store before doing WithViewStore in order to pick out only the pieces of state you need. I see could because it's not always necessary, especially at leaf nodes in the view.

Hope that's helpful.

1 Like

Wow thank you so much @mbrandonw! After seeing your face for hours upon hours in your pointfree.co videos, I feel like an actual celebrity responded to me! What an honor!

Your answer helped a light bulb go off in my head that suddenly made the concept of a ViewStore so much more clear for me (despite having watched many videos about it). I really felt blocked from moving forward with the architecture and I was literally ready to throw the towel in. Thank you for keeping me afloat!

:pray: :pray: :pray:

1 Like