Fundamentals
Reducer
Reducer in functional programming
let a = [1, 2, 3] let b = a.reduce(0) { partialResult, numberFromList in return partialResult + numberFromList } // reduce function: // input: // - initial result, // - a function (previous result, a new item -> a new result) // output: new value
Redux like architecture
- Reducer function: previous
state,action-> a newstate
- Store
- initial state
- reducer
- View
- Update based on the state from the store
- When user action happens, sends an action to the store, so reducer uses it to generate a new state
Why
- Ideas of functional programming
- pure function
- no side effect (returns result, doesn’t alter input or any other resource)
- consistent (no randomness in result, no dependency on systems beyond its control), output only depends on the input
- functions are first class citizens
- higher order functions
- advantages
- predictable
- centralized & consistent way to modify states
- testable
Adaptation in Swift
- Swift has
struct&enumwhich are value types (as opposed to a reference type likeclass) - If we have
class A { ... },let a = A(), if we dolet a2 = a,a2is a reference toa, so changes toa2also applies toasince they are the same object. - If we have
struct B { ... },let b = B(), if we dovar b2 = b,b2is a copy ofb, so changes tob2is only onb2and won’t affectb - Functions can have an
inoutparameter, which can change the parameter in-place
func increment(number: inout Int) { number += 1 }
Because the number is a value type, this is still considered a no side-effect / pure function even though it changes the number, because the number is just a copy and the change won’t affect other copies.
Swift’s reduce function also has a version that uses inout param for the function
let b = a.reduce(into: 0) { result, numberFromList in result += numberFromList }
Â
Side effects
- The reducer function are pure functions that just have consistent/predictable logic to update the state
- External dependencies, async code, code modifying external systems should be isolated/encapsulated as side effects
- reducer handles action → triggers → side effect → sends another action with results from the side effect → reducer handles the new action
- Caveat with TCA: with Swift dependency, it is ok that a reducer function use a dependency (even if it produces random value), as long as the dependency provides a test implementation that produces consistent result. So it’s not necessary to use a side effect for this case.
- Example: for one of the action enum cases, we can do
state.items.append(Item(id: self.uuid()))if theuuidfunction comes from the dependency and has a test implementation that creates consistent result - Side effects are mostly use for async use cases (e.g. delay by N seconds, network requests, Combine subscriptions, etc)
Composing the states & reducers together
- When there are two features, A and B
- A has a view, and a store (with its state, actions, and reducer)
- B has a view, and a store (with its state, actions, and reducer)
- From A, B can be opened, and A needs to know what happened in B (A is the parent feature & B is the child feature)
- TCA allows us to
- Scope out B’s store from A’s store:
- A’s state structure has a property that is B’s state
- A’s action enum has a case that is B’s action enum
- Call
store.scope(key path to B's state, case path to B's action)from A’s view - A can observe B’s actions
Â
Basic setup
@Reducer struct AFeature { @ObservableState struct State { ... } enum Action { case action1 case action2 } var body: some Reducer<State, Action> { Reduce { state, action in switch action { case .action1: ... // return some side effect, or .none case .action2: ... // return some side effect, or .none } } } }
In the reducer, for each action a side effect can be returned.
The side effect can be either
.none, or:Effect.run { // await can be used ... send(anotherAction) }
Effect.publisher { // return a Combine publisher }
Â
The view:
struct AView: View { let store: StoreOf<AFeature> var body: some View { Text(store.someProperty) Button("button") { store.send(.action1) } } }
Â
To create the view:
AView(store: Store(initialState: ...) { AFeature() }
Testing
Â
// !!! needs @MainActor let store = TestStore(initialState: AFeature.State(...)) { AFeature() } await store.send(.action1) { state in state.___ = ____ // implement the expected state changes in this closure } // if we expect a side effect happens with action1 which triggers action2, do: // await store.receive(.action2, valueOfAction2) { // $0.___ = ____ // expected state changes due to action2 // }
Â
Parent → Child integration
Tree based navigation
nil represents you are not navigated to a feature
- non-
nil represents an active navigation
Setup
// add a property for the state used by the child struct State { ... @Presents var childState: ChildFeature.State? } enum Action { ... case childAction(PresentationAction<ChildFeature.Action>) } var body: some ReducerOf<Self> { Reduce { state, action in ... } .ifLet(\.childState, action: \.childAction) { ChildFeature() } }
(
PresentationState + PresentationAction adds one additional case that can be sent to dismiss a presented feature)to present the child view:
// in the reducer body state.childState = ... // in SwiftUI, presenting with sheet .sheet( item: $store.scope( state: \.childState, action: \.childAction ), ) { store in NavigationStack { ChildView(store: store) .navigationTitle(...) } }
Â
Parent reducer can optionally handle (only if necessary) actions from child:
case childAction(.someAction): // do something
Â
Stack based navigation
- navigation stack as an array of states
Setup
/// root of the stack, cannot be popped struct AppFeature: Reducer { /// an inner reducer struct Path: Reducer { enum State { case detail(DetailFeature.State) } enum Action { case detail(StandupDetailFeature.Action) } var body: some ReducerOf<Self> { Scope(state: /State.detail, action: /Action.detail) { DetailFeature() } } } struct State { var list = ListFeature.State() var path = StackState<Path.State>() } enum Action { case path(StackAction<Path.State, Path.Action>) case list(ListFeature.Action) } var body: some ReducerOf<Self> { Scope( state: \.list, action: /Action.list ) { ListFeature() } Reduce { state, action in switch action { case .list: return .none case .path: return .none } } .forEach(\.path, action: /Action.path) { // similar to the `ifLet` in tree based navigation Path() } } }
struct AppView: View { let store: StoreOf<AppFeature> var body: some View { /// NavigationStack is on the top level so the features can be decoupled from it NavigationStackStore( self.store.scope(state: \.path, action: { .path($0) }) ) { ListView( store: self.store.scope( state: \.list, action: { .list($0) } ) ) } destination { state in switch state { case .detail: CaseLet( /AppFeature.Path.State.detail, action: AppFeature.Path.Action.detail, then: { store in DetailView(store: store) }) // if only supporting iOS 17+ this simplifies to // if let store = $0.scope( // state: \.detail, action: { .detail($0) } // ) { // StandupDetailView(store: store) // } } } } }
In the ListView:
var body: some View { List { ForEach(...) { NavigationLink( state: AppFeature.Path.State.detail(DetailFeature.State($0)) ) { RowView($0) } } } }
The
path action is a StackAction which is an enum that can be an action from a child, popFrom, or push. In the AppFeature reducer by handling .path we can access those actions.Â
Â