🎹

iOS The Composable Architecture (TCA) notes (wip)

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 new state
  • 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 & enum which are value types (as opposed to a reference type like class )
    • If we have class A { ... }, let a = A(), if we do let a2 = a , a2 is a reference to a , so changes to a2 also applies to a since they are the same object.
    • If we have struct B { ... }, let b = B(), if we do var b2 = b, b2 is a copy of b , so changes to b2 is only on b2 and won’t affect b
    • Functions can have an inout parameter, 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 the uuid function 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.
 
Â