How to compose states from independent modules to form a deep hierarchy?

Let's take an app which is structured as a graph of features.

GraphCombined

This app has the follwing qualities:

  • Each node of the graph is an independent feature module
  • Feature modules don't import other feature modules
  • Each feature module defines its own state
  • Some parts of the states overlap
  • Part of each state has to be incrementally loaded upon navigation
  • Features (not modules) can form cyclic dependencies

The following questions are posed:

  1. How to represent such state composition in the root (app state)?
  2. How a child feature can know when a part of its state changes and it needs to reload the remaining part?

If all states were defined in the same module, they could be represented in the root as follows.

struct AppState {
    let root: AState
}

struct StateA {
    var loadingState: LoadingState
    var selectedItem: StateB?
    var items: [Item]
}

struct StateB {
    var loadingState: LoadingState
    var item: Item
    var selectedItemDetail: StateC?
    var itemDetails: [ItemDetail]
}

However, state A cannot know about state B, because feature modules are not allowed to import each other (to avoid cyclic dependencies, to maximize parallel compilation, and enforce strict feature boundaries).


The following options were considered:

  1. All states are deconstructed into one app state without preserving the hierarchy.
    • At some point the app state will become unreasonably large and hard to maintain.
struct AppState {
    var loadingStateA: LoadingState
    var selectedItem: Item?
    var items: [Item]

    var loadingStateB: LoadingState
    var selectedItemDetail: ItemDetail?
    var itemDetails: [ItemDetail]
    
    var loadingStateC: LoadingState
    var additionalItemDetailData: ItemDetailData?
}
  1. Keep each state as a separate property in the app state without preserving the hierarchy.
    • Overlapping state properties won't be automatically updated across states.
    • Require creating sophisticated getters and setters and/or higher-order reducers.
    • As hierarchical structure is lost, a child state won't be automatically cleared when a parent state is cleared
struct AppState {
    let stateA: StateA
    var stateB: StateB?
    var stateC: StateC?
}
  1. Create wrappers around the states from the modules in the root to preserve the hierarchy.
    • Allows to automatically clear a child state when a parent state is cleared, but doesn't help with updating overlapping state properties.
    • Require creating sophisticated getters and setters and/or higher-order reducers
struct AppState {
    let wrappedStateA: WrappedStateA
}

struct WrappedStateA {
    let stateA: StateA
    var wrappedStateB: WrappedStateB?
}

struct WrappedStateB {
    let stateB: StateB
    var stateC: StateC?
}
  1. Create a shadow state hierarchy in the root that mimics the states from the modules.
    • Allows to automatically update overlapping properties and clear child states.
    • Requires almost a double amount of effort.
    • Require creating sophisticated getters and setters and/or higher-order reducers.
struct AppState {
    let root: AState
}

struct AppStateA {
    var loadingState: LoadingState
    var selectedItem: AppStateB?
    var items: [Item]
}

struct AppStateB {
    var loadingState: LoadingState
    let item: Item
    var selectedItemDetail: AppStateC?
    var itemDetails: [ItemDetail]
}

struct AppStateC {
    var loadingState: LoadingState
    let itemDetai: ItemDetail
    var additionalItemDetailData: ItemDetailData?
}

None of the options provide an elegant solution. Furthermore, Composable Architecture doesn't mention how to notify a child when a part of its state changes and it needs to reload data.

7 Likes

The system notifies a child anytime any state is changed, even if the change to the global state might not cause changes in the local (child) state. The remove duplicates functions specified in the ViewState filters out notifications that haven't changed the state of the child. There was a video from the series about this, https://www.pointfree.co/episodes/ep95-adaptive-state-management-state, though it may require a subscription. I don't come from a functional background and these videos have been an tremendous help in understanding the architecture.

When we observe state changes, we are dealing with the end product, which is used for rendering. However, there is no explicit mechanism for reacting to those changes in a way that allows feeding them back to Reducer as Action. In my example, I've tried to show that if some outside action will change a part of your local state, you might need to react to this by changing other parts of your state (for example, invalidating loaded items and starting to load new ones).

I can imagine this can be done by observing specific state changes from onReceive. However, it feels like an ad-hoc solution, which under certain circumstances can even lead to dangerous feedback loops across the system. Moreover, it might be not clear why all of a sudden you are observing this specific part of your state.

.onReceive(store.$state.map(\.item).removeDuplicates()) { item in
    self.store.send(.itemChanged(item))
}

Are you suggesting a variation of ifLet?

public func ifChange<Value,Wrapped>(
on value: KeyPath<State, Value>,
then unwrap: @escaping (Store<Wrapped, Action>) -> Void
) -> Cancellable where State == Wrapped? {
self
.scope(
state: { state in
state
.removeDuplicates(by: { $0[keyPath: value] !=$1[keyPath: value] })
},
action: { $0 }
)
.sink(receiveValue: unwrap)
}

(I haven't tried the above so it probably won't compile;-))
then your code above might be

store.onChange(\MyState.item) { store.send(.resetCache) }

Hmm, I tried the above using the demos and got a runtime assert. I guess the architecture doesn't allow recursive calls to send. Have you tried implementing using any of the solutions above. Are the transforms really that complicated?

I was thinking of this too! I would like to "run an action" on other reducer when a value in the global state changes. Maybe some sort of pullback like reducer could be done? If each substate would have their own loadingState and a generic reducer would route the changes.

public func onChange<GlobaState, State, GlobalAction, Environment>(
    state: KeyPath<GlobaState, State>,
    sendChangeAs: @escaping (State) -> GlobalAction
) -> Reducer<GlobaState, GlobalAction, Environment> where State: Equatable {

    var oldState: State?
    return Reducer<GlobaState, GlobalAction, Environment> { (globalState, _, env) in

        let newState = globalState[keyPath: state]
        defer { oldState = newState }
        return newState != oldState
            ? pure(sendChangeAs(newState))
            : .none
    }
}

where one would have actions for the change on each module like

struct AppState {
    var stateA: StateA
    var stateB: StateB
}

enum AppAction {
  case actionA(ActionA)
  case actionB(ActionB)
}

enum ActionA {
  case doStuffToChangeLoadingState
}

enum ActionB {
  case loadingStateChanged(LoadingState)
}

then changes would be routed in the combined app reducer:

let aReducer = Reducer<StateA, ActionA, ...>(...)
let bReducer = Reducer<StateB, ActionB, ...>(...)

let appReducer = Reducer<AppState, AppAction, ...>.combine(
  onChange( // Routes changes from StateA.loadingState to StateB.loadingState as an action
    state: \AppState.stateA.loadingState,
    sendChangeAs: { AppAction.actionB(.loadingStateChanged($0) } 
  ),
  aReducer.pullback( // Regular pullback for actually changing the LoadingState
    state: \AppState.stateA,
    action: /AppAction.actionA
  ),
  bReducer.pullback( // Regular pullback for StateB 
    state: \AppState.stateB,
    action: /AppAction.actionB
  )
)

The onChange reducer is one way though so if ReducerB would change LoadingState it would not go to StateA. The advantage here would be that StateA and B do not need to be computed properties in AppState. Disadvantage is that the routing is pushed to reducer and each submodule needs to have duplicate actions and handling of change of LoadingState. I'm not sure if this could be further refined.

How does the onChange then call the bReducer? Should it be constructed with a reference to the bReducer?

maybe like so
onChange( // Routes changes from StateA.loadingState to StateB.loadingState as an action
state: \AppState.stateA.loadingState,
sendChangeAs: { bReducer.run($$0,AppAction.actionB(.loadingStateChanged($0)) }
),

I've realised that almost always when you need to "synchronise" states of unrelated features, you are dealing with state outside of your app (for example added an item to the list which is stored on the backend).

So in this case it's quite obvious, that source of truth is not the app state, but the remote one (in effects world), so it's easier to create nice cacheing or at least notifying gateway/repository, which will emit Actions to all substates that something changed in outside world, while all reducers of substates can react to the notification.

And deep nesting of states (even recursive) can be achieved by the method I've posted here: Recursive navigation

In order to decouple UI's state from the app ones, you can define local ones right with views/view controllers, maybe even nested to view.

This approach keeps UI part completely modularised, so modules/features don't import each other. App state and all features substates live in one module, which can import many other modules with services which are used in Environment.

No need, the store will propagate the action emitted by onChange to bReducer on the next round.

You are right! Maybe we could make some generic helper that could subscribe to environment. Like if there is a @Published var loadingState in the environment, one could combine some generic reducer with the module reducer to get the updates.

If you model your reducer after the debug reducer it could be

	public func onChange<LocalState>(
		state toLocalState: @escaping (State) -> LocalState,
		sendChangeAs: @escaping (LocalState)->Action
	) -> Reducer<State, Action, Environment> where LocalState: Equatable {
		
		return Reducer<State, Action, Environment> { state, action, env in
			
			let previousState = toLocalState(state)
			let effects = self.run(&state, action, env)
			let newState = toLocalState(state)
			
			guard newState != previousState else { return effects }
			
			return .merge(.init(value:sendChangeAs(newState)),
				effects)
		}
	}

Doing it this way isolates the mutating reducer and ensures that it is A and not B.

And the resulting AppReducer

let appReducer = Reducer<AppState, AppAction, ...>.combine(
aReducer.pullback( // Regular pullback for actually changing the LoadingState
    state: \AppState.stateA,
    action: /AppAction.actionA
  ).onChange( // Routes changes from StateA.loadingState to StateB.loadingState as an action
    state: \AppState.stateA.loadingState,
    sendChangeAs: { AppAction.actionB(.loadingStateChanged($0) } 
  ),
  bReducer.pullback( // Regular pullback for StateB 
    state: \AppState.stateB,
    action: /AppAction.actionB
  ).onChange( // Routes changes from StateB.loadingState to StateA.loadingState as an action
    state: \AppState.stateB.loadingState,
    sendChangeAs: { AppAction.actionA(.loadingStateChanged($0) } 
  )
)

There is also this solution which is more direct

	public func onChange<LocalState>(
		state toLocalState: @escaping (State) -> LocalState,
		didChange: @escaping (inout State, Environment)->Effect<Action,Never>
	) -> Reducer<State, Action, Environment> where LocalState: Equatable {
		
		return Reducer<State, Action, Environment> { state, action, env in
			
			let previousState = toLocalState(state)
			let effects = self.run(&state, action, env)
			let newState = toLocalState(state)
			
			guard newState != previousState else { return effects }
			
			return .merge(effects,
						  didChange(&state, env))
		}
	}

This way the didChange function can examine the state just after the change and before effects might make more modifications.

The AppReducer could then have

let appReducer = Reducer<AppState, AppAction, ...>.combine(
aReducer.pullback( // Regular pullback for actually changing the LoadingState
    state: \AppState.stateA,
    action: /AppAction.actionA
  ).onChange( 
      state: \AppState.stateA.loadingState) { state, environment in
     return bReducer.run( &state.stateB, .loadingStateChanged, environment)
  },
  bReducer.pullback( // Regular pullback for StateB 
    state: \AppState.stateB,
    action: /AppAction.actionB
  ).onChange( 
      state: \AppState.stateB.loadingState) { state, environment in
     return aReducer.run( &state.stateA, .loadingStateChanged, environment)
  }
)

I like the idea to put the onChange as a reducer method so it does not pollute the global namespace. This isolates the changes to only come from a specific reducer, but leaves it possible for other reducers to mutate the state. I don't think it's an issue if stateA is only mutated by aReducer (using fileprivate(set) for example)

Also my version of onChange would miss any changes made by reducers run after it in the Reducer.combine() so I'm starting to think that the Store would be more natural fit for observing changes to the state.

Terms of Service

Privacy Policy

Cookie Policy