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 NavigationAction
s. 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 Value
s 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 ReadOnlyCasePath
s 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
.