Scoped store state, but wrapped in a different struct

If I have some state that holds an array of items, I can create a scoped store that operates on a single item of that list using the forEach function on the reducer. And when I change the single item, it also updates inside of the list. Great!

struct Model: Equatable, Identifiable {
  let id: Int
}

struct AppState: Equatable {
  var models: IdentifiedArrayOf<Model> = .init()
}

enum AppAction: Equatable {
  case model(id: Int, action: ModelAction)
}

enum ModelAction: Equatable {}

let modelReducer = Reducer<Model, ModelAction, AppEnvironment> { state, action, _ in
  return .none
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  Reducer { state, action, _ in
    return .none
  },
  modelReducer(
    state: \.models,
    action: /AppAction.model(id:action:),
    environment: { _ in AppEnvironment() }
  )
)

But what if I want the single item state to have a bit more.. state? Not just the bare Model. For example:

struct ModelState: Equatable {
  var model: Model
  var somethingElse: Bool = false
}

let modelReducer = Reducer<ModelState, ModelAction, AppEnvironment> { state, action, _ in
  return .none
}

I can't use a forEach function anymore, since IdentifiedArrayOf<Model> doesn't map to ModelState. So I guess I need to use the pullback function to manually transform this? How can I do that in a way so that I can still create a scoped store where modifying the single Model also gets updated in the AppState's array?

1 Like

Have you looked at the list-based navigation demos? They have the idea of a "selection," which could be used to introduce additional state:

Interesting, thanks for the pointer. After looking at that example I came up with the following code:

import ComposableArchitecture
import Foundation

struct Model: Identifiable, Equatable {
  let id: Int
  var title: String
}

struct AppState: Equatable {
  var models: IdentifiedArrayOf<Model> = .init()
  var selection: Identified<Model.ID, ModelState>?
}

struct ModelState: Equatable {
  var model: Model
  var somethingElse: Bool = false
}

enum AppAction: Equatable {
  case model(ModelAction)
  case selectModel(Model.ID)
}

enum ModelAction: Equatable {
  case changeTitle(String)
}

struct AppEnvironment {}

let modelReducer = Reducer<ModelState, ModelAction, AppEnvironment> { state, action, _ in
  switch action {
    case .changeTitle(let title):
      state.model.title = title
      return .none
  }
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  Reducer { state, action, _ in
    switch action {
      case .model:
        return .none

      case .selectModel(let id):
        if let model = state.models[id: id] {
          state.selection = Identified(ModelState(model: model), id: id)
        }
        return .none
    }
  },
  modelReducer
    .pullback(
      state: \Identified.value,
      action: .self,
      environment: { $0 }
    )
    .optional
    .pullback(
      state: \AppState.selection,
      action: /AppAction.model,
      environment: { $0 }
    )
)
.debug()

But sadly it doesn't work as I expect: when I change the single model in a scoped store, it's not also changed in the list.

let store = Store<AppState, AppAction>(
  initialState: AppState(models: .init([Model(id: 1, title: "Kevin")]), selection: nil),
  reducer: appReducer,
  environment: AppEnvironment()
)

let viewStore = ViewStore(store)
viewStore.send(.selectModel(1))

let scoped = store.scope(
  state: { $0.selection },
  action: { AppAction.model($0) }
)

let scopedViewStore = ViewStore(scoped)
scopedViewStore.send(.changeTitle("Stephen"))

Obviously I'm missing a crucial bit here :thinking:

Yeah unfortunately you need to do some kind of coordination. In the above example we update the row's count when the row is "deselected":