Using custom reducers to make shared data even easier with TCA

You can jump straight into the code here: swift-composable-subscriber.

In my recent article, Shared data in a TCA app, I detailed how to use dependencies to store shared data that can be subscribed to from anywhere in your app.

This means that anywhere in your app a reducer can return a run effect from its body, loop on the values from an AsyncStream, and return some other action to deal with the updated values.

case .task:
  return .run { send in
    for await value in await someDependency.valueStream() {
      await send(.newValueReceived(value))
    }
  }

This makes it really easy to subscribe to any stream of data in your app and even keeps your reducers in sync when the data is updated.

I then received a question on Mastodon from Ben Lings:

Great stuff! This pattern has been very useful in the app we are building. I've been considering how to encapsulate accessing this sort of dependency in a higher order reducer, to reduce the task-foreach-send boilerplate. Any thoughts on that?

View the original Mastodon post.

And I was thinking about this in the app I'm working on currently. There are a few reducers that rely on multiple streams of data. This very quickly started to flood the reducer body with these same lines over and over again. We needed a way to refactor this out to make it easier to maintain whilst still giving us the control that we have here.

This is where a custom reducer can come in to help us.

The problem we are trying to solve can be described as:

We can create this by creating our own custom reducer.

I created a SubscriberReducer that is generic over:

By doing this we can create several helper functions like:

.subscribe(to: myDependency.valueStream, on: \.task, with: \.newValueReceived)

This takes the five lines above and reduces it to a single line. And it allows us to add multiple subscriptions very easily.

If the AsyncStream type matches the action then we can just do this:

Reduce { state, action in
  // This is the regular reducer.
}
.subscribe(to: myDependency.valueStream, on: \.task, with: \.newValueReceived)

If the type of the AsyncStream doesn't match the action then you can provide a simple transform:

Reduce { state, action in
  // This is the regular reducer.
}
.subscribe(to: myDependency.intStream, on: \.task, with: \.newStringReceived) { intValue in
  "\(intValue)"
}

And even if we have more complex logic that we need to run, we can do that also:

Reduce {
  // This is the regular reducer.
}
.subscribe(
  to: myDependency.stream,
  on: \.some.trigger.action
) { send, streamElement in
  await send(.responseAction)
  await otherDependency.doSomethingElse(with: streamElement)
}

This has been great for us as it has allowed us to greatly simplify the code involved when we have multiple subscriptions firing off. And it makes it much easier to read also:

Reduce { state, action in
  // This is the regular reducer.
}
.subscribe(to: myDependency.userStream, on: \.task, with: \.newUserDataReceived)
.subscribe(to: myDependency.chatStream, on: \.task, with: \.newChatMessageReceived)
.subscribe(to: myDependency.reactionStream, on: \.task, with: \.newReactionReceived) {
  Reaction(payload: $0)
}

Custom reducers can really help shift a lot of the cognitive load of your app so that you don't have to worry about it at code time and it makes for easier reading and understanding too.

What custom reducers have you been able to create to help you offload repetitive work and reduce cognitive load?

I hope this has helped you. You can find my code and SPM package on GitHub: swift-composable-subscriber.