Let's take an app which is structured as a graph of features.
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:
- How to represent such state composition in the root (app state)?
- 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:
- 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?
}
- 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?
}
- 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?
}
- 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.