[Proposal] Introduce ReadOnlyCasePath

Read-only case paths

This proposal outlines the concept of read-only case paths. We take a look at how read-only CasePaths can be used in reducer composition and how they allow to share reducers.

Motivation

Users of the composable architecture can compose reducers by using operators like pullback or forEach which leverage case paths to determine which reducer should be run for an incoming action. The current pullback signature takes a concrete CasePath<GlobalAction, Action> as toLocalAction which maps a global case to a local case. However, pullback itself does not mutate the underlying enum value and therefore only depends on CasePath's extract functionality. Let's take a look at a simplified example application action domain for an application that consists of two screens A and B. Both screens have screen specific actions but also emit NavigationActions. We will outline three different approaches to reducer composition for this scenario.

enum NavigationAction {
    case moveTo(Screen)
}

enum AppAction {
    case screenA(ScreenAAction)
    case screenB(ScreenBAction)
}

enum ScreenAAction {
    case userTappedContinue()
    case navigate(NavigationAction)
}

enum ScreenBAction {
    case userTappedCancel()
    case navigate(NavigationAction)
}

Approach 1: Composing reducers per screen

let navigationReducer = Reducer<NavigationState, NavigationAction, NavigationEnvironment> { state, action, environment in 
    switch action {
        case let .moveTo(screen):
            // mutate NavigationState, return necessary side effects...
    }
}

let screenAStateReducer = Reducer<ScreenAState, ScreenAAction, ScreenEnvironment> { state, action, environment in 
    switch action {
        case .userTappedContinue:
            return Effect(.navigate(.moveTo(.screenB)))
        case .navigate:
            return .none
    }
}

let screenAReducer = Reducer<ScreenAState, ScreenAAction, ScreenAEnvironment>.combine(
    screenAStateReducer,
    navigationReducer
        .pullback(
            state: \ScreenAState.navigationState,
            action: /ScreenAAction.navigate,
            environment: { ScreenAEnvironment.toNavigationEnvironment() }
        )
)

In this approach, we declare two reducers: a 'global' reducer that handles navigation actions and a 'local' reducer that only handles actions related to Screen A and ignores emitted NavigationActions. Those two reducers are combined into a screen reducer. The implementation for Screen B would be analogous and leave us with the following reducer tree for the whole application:

root
⎣__ Screen A Reducer
    ⎣__ screenAStateReducer
    ⎣__ navigationReducer
⎣__ Screen B Reducer
    ⎣__ screenBStateReducer
    ⎣__ navigationReducer

This approach has one big benefit: all reducers the screen depends on are condensed in one place and it's easy to pinpoint which reducers come into play when an action is emitted on this particular screen. However, the ScreenAState now needs to contain the full NavigationState so that we can reference it via the navigationState keypath in the pullback reducer. The same is true for the ScreenAEnvironment which now needs to hold all NavigationEnvironment dependencies.

Approach 2: 'Bubbling up' actions into the root App reducer

To mitigate the approach 1's drawbacks, approach 2 'bubbles up' actions into the root reducer by leveraging the toGlobalAction in the Store.scope operator. To do so, we add a navigate case to the AppAction enum. When declaring the screen-related stores, we map the local actions navigate actions to AppAction navigate actions. The mapped navigation actions are then handled in the app reducer.

enum AppAction {
    case screenA(ScreenAAction)
    case screenB(ScreenBAction)

    case navigate(NavigationAction)
}

let screenAStore = appstore.scope(
    state: \.screenAState,
    action: { action in
        switch action {
            case .userTappedContinue:
            return .screenA(action)
            case let .navigate(navigationAction):
            return .navigate(navigationAction)
        }
    }
)

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
    navigationReducer
        .pullback(
            state: \AppState.navigationState,
            action: /AppState.navigate,
            environment: { AppState.toNavigationEnvironment() }
        ),
    screenAReducer
        .pullback(
            state: \AppState.screenAState,
            action: /AppState.screenA,
            environment: { AppState.toScreenAEnvironment() }
        ),
    screenBReducer
        .pullback(
            state: \AppState.screenBState,
            action: /AppState.screenB,
            environment: { AppState.toScreenBEnvironment() }
        )
}

The implementation for screen B is analogous. We end up with the following reducer tree:

root
⎣__ NavigationReducer
⎣__ Screen A Reducer
⎣__ Screen B Reducer

We have solved the reducer duplication and now no longer need to carry through our navigation environment and state into the screen state. However, it is now harder to track where actions are handled as screen reducers no longer combine all their dependencies. Additionally, we added complexity in the toGlobalAction closure as we're now required to map from a local navigation action to a global navigation action.

Proposed solution

Approach 3: Combining read-only case paths in the root reducer, allowing shared reducers

Approach 3 is mainly an ergonomic improvement over approach 2. Instead of explicitly mapping a local navigation action to a global navigation action, we leverage read-only case paths and run the global navigation reducer for any local action that can be mapped to a NavigationAction. We remove the navigate case from the app action and concatenate case paths to access the underlying actions.

enum AppAction {
    case screenA(ScreenAAction)
    case screenB(ScreenBAction)
}

let screenAStore = appstore.scope(
    state: \.screenAState,
    action: { .screenA($0) }
)

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
    navigationReducer
        .pullback(
            state: \AppState.navigationState,
            action: ReadOnlyCasePath<AppAction, NavigationAction>.matchingAnyOf(
                /AppState.screenA..ScreenAAction.navigate,
                /AppState.screenB..ScreenBAction.navigate,
            ),
            environment: { AppState.toNavigationEnvironment() }
        ),
    screenAReducer
        .pullback(
            state: \AppState.screenAState,
            action: /AppState.screenA,
            environment: { AppState.toScreenAEnvironment() }
        ),
    screenBReducer
        .pullback(
            state: \AppState.screenBState,
            action: /AppState.screenB,
            environment: { AppState.toScreenBEnvironment() }
        )
}

In this approach, we leverage a newly introduced static function on ReadOnlyCasePath that allows to extract Values from any matching Case. It's analogous to listing multiple cases with the same associated values in a switch case statement (i.e. case let .a(number), case .b(number)). Let's take a look at a potential implementation of such a .matchingAnyOf function to understand why this wouldn't be possible with the current case path implementation.

extension CasePath {
    static func matchingAnyOf(_ paths: CasePath<Root, Value>...) -> CasePath<Root, Value> {
        CasePath(embed: { value in
            paths.first?.embed(value) // 1: Doesn't necessarily match the previously matched path
            paths.forEach { $0.embed(value) } // 2: Embeds the value in all paths. Also not what we want.
            fatalError("We cannot return a Root, as there is no root case for this type of action")
        }, extract: { root in
            var value: Value?
            for path in paths {
                value = path.extract(from: root)
                if value != nil { break }
            }
            return value
        })
    }
}

The extract implementation iterates over the provided paths and returns the extracted value once found. However, the embed function is simply not implementable. We cannot determine which paths need to be updated with the new value. Additionally, we cannot return a Root value, as AppAction no longer has a case that encapsulates a NavigationAction.

Read-only CasePath

Read-only case paths only support extracting values and not embedding values. Their initialiser therefore do not require an embed closure. This would allow us to implement functions like .matchingAnyOf and improve the ergonomics of the composable architecture.

ReadOnlyCasePath(
    extract: { root in
            var value: Value?
            for path in paths {
                value = path.extract(from: root)
                if value != nil { break }
            }
            return value
    }
)

Alternative considered

Changing pullback

public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    action toLocalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
)

Pullback currently takes a case path. We could change add signature to instead a function () -> Action and provide an overload for the case path case.

Source compatibility

By introducing a ReadOnlyCasePath instead of changing the existing CasePath to be read-only and introducing a WriteableCasePath (analogously to WriteableKeyPath), all source code stays compatible.

Future directions

We could explore other properties of ReadOnlyCasePaths as matchingAnyOf is only one of a set of possible operators. We could also investigate in which cases a WriteableKeypath is used over a read-only Keypath.

3 Likes

Just my two cents: as the library is quite new, I'd rather break source compatibility and rename CasePath to WriteableCasePath and use CasePath for this feature.

IIRC there was already an experiment on a branch with something like this.

2 Likes

Turns out, while writing this proposal, I missed Line 262 in the pullback implementation. So my approach #3 wouldn't be implementable without additional changes to the pullback function.

One idea, would be to separate the embed and extract function from the passed case path and change the parameter list to accept two closures embed / extract.

  public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    extract: (GlobalAction) -> Action,
    embed: @escaping (Action) -> GlobalAction,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
  ) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment> {
    .init { globalState, globalAction, globalEnvironment in
      guard let localAction = extract(from: globalAction) else { return .none }
      return self.reducer(
        &globalState[keyPath: toLocalState],
        localAction,
        toLocalEnvironment(globalEnvironment)
      )
      .map(embed)
    }
  }

  public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    action toLocalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
  ) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment> {
    .pullback(
      state: state, 
      extract: toLocalAction.extract, 
      embed: toLocalActtion.embed, 
      environment: environment
    )
  }

  public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    toLocalAction: ReadOnlyCasePath<GlobalAction, Action>,
    toGlobalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
  ) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment> {
    .pullback(
      state: state, 
      extract: toLocalAction.extract, 
      embed: toGlobalAction.embed, 
      environment: environment
    )
  }

This would allow us to provide separate closures for embedding and extracting. However, one could argue that this doesn't really improve the pullback operator as it muddies up its signature. The 'only' benefit one would gain is that we would no longer have to map local 'global' actions to global actions as part of the toGlobalAction parameter in Store.scope. Additionally, the global case path would now need to be part of the toLocalAction ReadOnlyCasePath combined case path so that returned actions as part of effects are correctly handled.