How should sibling features talk to each other via a parent feature?

New to TCA, loving it so far! I'm making a toy app that applies TCA. It's just two features: DiceRoller and Fibonaccizer. DiceRoller is just a button that rolls a 6-sided die and displays the result. Fibonaccizer is just a button that, when clicked, increments n and displays the nth Fibonacci number.

Both features work well in isolation (aside from integer overflow when I get to the 92nd Fibonacci number but that isn't the point of my exercise). I want to create a new feature that composes both DiceRoller and Fibonaccizer. When I roll the die, I'd like the roll result to be used as n in Fibonaccizer.

DiceRoller:

import Foundation
import SwiftUI
import ComposableArchitecture

@Reducer
struct DiceRollerFeature {
    struct State: Equatable {
        var diceRollResult: Int?
        
        var diceRollResultString: String {
            if let diceRollResult {
                return "\(diceRollResult)"
            } else {
                return "Nothing yet"
            }
        }
    }
    
    enum Action {
        case rollButtonTapped
    }
    
    @Dependency(\.rng) var rng
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .rollButtonTapped:
                state.diceRollResult = self.rng.roll(1, 6)
                return .none
            }
        }
    }
}

struct DiceRoller: View {
    let store: StoreOf<DiceRollerFeature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text("Dice Roller")
                    .font(.title)
                Text("Result: \(viewStore.diceRollResultString)")
                Button("Roll d6") {
                    viewStore.send(.rollButtonTapped)
                }
            }
        }
    }
}

#Preview {
    DiceRoller(
        store: Store(initialState: DiceRollerFeature.State()) {
            DiceRollerFeature()
        }
    )
}

Fibonaccizer

import Foundation
import SwiftUI
import ComposableArchitecture

@Reducer
struct FibonaccizerFeature {
    struct State: Equatable {
        var n: Int = 1
        var nthFibonacciNumber: Int = 1
    }
    
    enum Action {
        case fibonaccizeButtonTapped
        case receiveNToFibonaccize(Int)
    }
    
    @Dependency(\.fib) var fib
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .fibonaccizeButtonTapped:
                state.n += 1
                state.nthFibonacciNumber = self.fib.getNthFibonacciNumber(state.n)
                return .none
            case .receiveNToFibonaccize(let n):
                state.n = n
                state.nthFibonacciNumber = self.fib.getNthFibonacciNumber(state.n)       
                return .none
            }
        }
    }
}

struct Fibonaccizer: View {
    let store: StoreOf<FibonaccizerFeature>
    
    var formatter: NumberFormatter {
        let numberFormatter = NumberFormatter()
        numberFormatter.numberStyle = .ordinal
        return numberFormatter
    }
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Text("Fibonaccizer")
                    .font(.title)
                Text("The \(formatter.string(for: viewStore.n)!) Fibonacci number is \(viewStore.nthFibonacciNumber)")
                Button("Fibonaccize!") {
                    viewStore.send(.fibonaccizeButtonTapped)
                }
            }
        }
    }
}

#Preview {
    Fibonaccizer(
        store: Store(
            initialState: FibonaccizerFeature.State()) {
                FibonaccizerFeature()
            }
    )
}

And my incomplete attempt at the parent feature/view FibDice that tries to compose together DiceRoller and Fibonaccizer:

import Foundation
import SwiftUI
import ComposableArchitecture

@Reducer
struct FibDiceFeature {
    struct State: Equatable {
        var diceRollerState = DiceRollerFeature.State()
        var fibonaccizerState = FibonaccizerFeature.State()
    }
    
    enum Action {
        case diceRollerAction(DiceRollerFeature.Action)
        case fibonaccizerAction(FibonaccizerFeature.Action)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .diceRollerAction(.rollButtonTapped):
                return .none
            case .fibonaccizerAction(_):
                return .none
            }
        }
    }
}

struct FibDice: View {
    let store: StoreOf<FibDiceFeature>
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            DiceRoller(store: viewStore.)
            Fibonaccizer(store: store)
        }
        
    }
}

#Preview {
    FibDice()
}

My questions:

  1. Am I on the right track by using a parent feature that composes together DiceRoller and Fibonaccizer?
  2. What state and actions should I define for the parent feature FibDice?
  3. Should the DiceRoller or Fibonaccizer know anything about the parent feature? My gut says no since that would make it hard to develop either child feature in isolation.

Thanks!

Hey @cherologist ! Have you already heard about the delegate approach? It can be handy to send and listen to actions from a child to a parent. I think it could help you to send actions from a child feature, listen to them on a parent feature, and then trigger actions on another child feature.

In addition to what @otondin mentioned, there is a bit more work you need to perform in your features to fully integrate them. First, in FibDiceFeature you need to compose the DiceRollerFeature and FibonaccizerFeature into the reducer like this:

  var body: some ReducerOf<Self> {
    Scope(state: \.diceRollerState, action: \.diceRollerAction) {
      DiceRollerFeature()
    }
    Scope(state: \.fibonaccizerState, action: \.fibonaccizerAction) {
      FibonaccizerFeature()
    }
    Reduce { state, action in
      …
    }
  }

And then in the view you need to scope the main store down to each child feature and pass those stores to the child views:

  var body: some View {
    DiceRoller(store: self.store.scope(state: \.diceRollerState, action: \.diceRollerAction))
    Fibonaccizer(store: store.scope(state: \.fibonaccizerState, action: \.fibonaccizerAction))
  }
1 Like

Thanks for the tip about the delegate approach! Will read up on that.

1 Like

Ah I was trying to do viewStore.scope(...), no wonder I hit a wall. This is what I needed.

As I understand it now, Scope conforms to Reducer which embeds a child reducer in a parent reducer. While Store.scope(...) narrows the scope of a Store into something appropriate for use by child features.

Thanks Brandon (and Stephen and all of the contributors) for making TCA happen!

Yes, that is exactly right.

Just wanted to add this: Episode #222: Composable Navigation: Tabs

This video (subscriber-only) made the parent-child feature composition crystal clear and also touches on the Delegate pattern.

1 Like