This is a post about The Composable Architecture, aka TCA. If you are not using it, I highly recommend you check it, even if just for learning some things that can be applied to other architectures.
Updated March 20: The suggested approach doesn’t work for TCA; sorry for the confusion. I tried it and it seemed to work, but I failed to consider all the consequences of using TCA this way. Check the end of the post for more info.
Effects in TCA are usually run in response to an action. For example:
struct Feature: Reducer {
@Dependency(\.client) var client
enum Status {
case ready
case loading
case loaded
case error
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appear:
state.status = .loading
return .run { send in
let loaded = try await client.load()
send(.loaded(loaded))
}
case let .loaded(value):
// Do something with value
}
}
}
}
This is the recommended approach on TCA (or at least the one it’s usually shown). It feels natural and easy to use, but there are some drawbacks to it.
First, the state status is not linked to the actual effect, so it’s very easy to have them loose track of each other:
struct Feature: Reducer {
@Dependency(\.client) var client
enum Status {
case ready
case loading
case loaded
case error
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appear:
// We forgot to set the status to .loading, but the effect will be running anyway
return .run { send in
let loaded = try await client.load()
send(.loaded(loaded))
}
case let .loaded(value):
// Do something with value
}
}
}
}
We can also change the state of a child feature from a parent all we want, but the effects won’t be triggered.
struct ParentFeature: Reducer {
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appear:
// We change the state of the child to loading, but the effect won't run
state.child.status = .loading
// More cases
}
}
}
}
Deriving effects from state changes
Let’s see a different way:
struct Feature: Reducer {
@Dependency(\.client) var client
enum Status {
case ready
case loading
case loaded
case error
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appear:
state.status = .loading
case let .loaded(value):
// Do something with value
}
}.onChange(\.status) { status in
Reduce { state, action in
switch status {
case .loading:
return .run { send in
let loaded = try await client.load()
send(.loaded(loaded))
}
case .ready, .loaded, .error:
return .none
}
}
}
}
}
What is the advantage of such approach? Mainly, it completely binds the status to the effect. This means we can’t have a loading status with no effect running, they can’t get out of sync, which can easily happen in the typical way. Also, this means we drive the effects from the status, which means we can:
- Easily perform effects on multiple child features from a root feature. If, for example, we want to prefetch image thumbnails scattered in our application, we can just change each feature to loading, and everything will just work.
- Recover the app state from a serialized value. If effects are derived from actions, we can’t just recover the serialized state of an app to return to the previous state. Some feature might have a loading status, but that doesn’t mean the corresponding effect is running. Here we derive the effects from state, so loading a stored state means we re-run all the needed in-flight effects. This is how an app that takes care of good UX should work: it should restore everything as it was before. This is how the world around us works: if we leave a pencil at the table it will stay there; it won’t magically go back to an initial state. Apple has introduced many APIs over the years to try to make it easy to do this, but it has never worked. Very few apps actually do that, because the effort required to do it well doesn’t seem worth it. However, if we derive effects from the state with TCA, this is trivial to do.
Of course there are cons too.
First, we might need additional states for each effect that we need to run. We also need that status even if we are not using it in the view, because we need it for performing the effects, so even if we don’t have a spinner we still have to add the loading state. This makes all the states more explicit, so it’s not all bad.
Finally, it’s also more verbose, though there are ways to mitigate that:
enum LoadStatus {
case ready
case loading
case loaded
case error
}
extension Reducer {
func onLoading(
_ path: KeyPath<State, LoadStatus>,
perform: @escaping (State) -> Effect<Action>
) -> some Reducer<Self> {
self.onChange(\.status) { status in
Reduce { state, action in
switch status {
case .loading:
return perform(state)
case .ready, .loaded, .error:
return .none
}
}
}
}
}
struct Feature: Reducer {
@Dependency(\.client) var client
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appear:
state.status = .loading
case let .loaded(value):
// Do something with value
}
}.onLoading(\.status) { state in
.run { send in
let loaded = try await client.load()
send(.loaded(loaded))
}
}
}
}
Here we would create a global LoadStatus that we can reuse across multiple features.
We can even hint our app developers that we want to perform effects only by reacting to state changes:
public struct Update<State, Action>: Reducer {
@usableFromInline
let reduce: (inout State, Action) -> Void
@inlinable
public init(
_ reduce: @escaping (_ state: inout State, _ action: Action) -> Void
) {
self.reduce = reduce
}
@inlinable
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
self.reduce(&state, action)
return .none
}
}
extension Reducer {
public func runOnChange<Value>(
of path: KeyPath<State, Value>,
run: @escaping (_ current: State, _ previous: State) -> Effect<Action>
) -> some ReducerOf<Self>
where Value: Equatable {
Reduce { state, action in
let previous = state
let effect = self.reduce(into: &state, action: action)
guard previous[keyPath: path] != state[keyPath: path] else {
return effect
}
return effect.merge(with: run(state, previous))
}
}
}
extension Reducer {
public func runOnChange<Value>(
of path: KeyPath<State, Value>,
to value: Value,
run: @escaping (_ current: State, _ previous: State) -> Effect<Action>
) -> some ReducerOf<Self>
where Value: Equatable {
Reduce { state, action in
let previous = state
let effect = self.reduce(into: &state, action: action)
let newValue = state[keyPath: path]
guard previous[keyPath: path] != newValue, newValue == value else {
return effect
}
return effect.merge(with: run(state, previous))
}
}
}
which can be used like this:
struct Feature: Reducer {
@Dependency(\.client) var client
enum Status {
case ready
case loading
case loaded
case error
}
public var body: some ReducerOf<Self> {
Update { state, action in
switch action {
case .appear:
state.status = .loading
case let .loaded(value):
// Do something with value
}
}.runOnChange(of: \.status, to: .loading) { status, _ in
.run { send in
let loaded = try await client.load()
send(.loaded(loaded))
}
}
}
}
Not as bad as before, and we enforce the behavior that we want. We can’t create effects in Update, and we can’t even know which action produced the change in the runOnChange function.
In summary, deriving effects from actions feels more natural, but using the state changes approach offers significant advantages that shouldn’t be overlooked. If you ever want the ability to restore your app from a specific state, or if you want to trigger child effects from a parent, you might want to consider this approach.
Updated March 20: Unfortunately, this doesn’t work. This onChange will only detect changes in the current reducer, so changes applied from parents won’t be visible here. Also, restoring the state means we won’t be able to detect changes either. The architecture is not designed for this, so it’s better not to try to force this. Restoring the state from serialized data can still be done, but it’s better to change the state before serialization so that we switch to a position that can be easily recovered. For example, we should change any loading state into a ready state, and then when the app launches again it will be easily recoverable.