Alexito's World

A world of coding 💻, by Alejandro Martinez

Observe actions in The Composable Architecture

One of the beautiful things about Pointfree's Composable Architecture is how you can start using it on a smaller part of your app. It makes it very easy to improve a codebase without having to stop the press, which is something that I always require of any new technology.

One inconvenience is that the principal object of the architecture, the Store, doesn't have a way to observe the actions that it receives from the outside, the only one that gets actions is the reducer and that is part of your TCA domain. This makes sense since the star of the show is the state.

The state should drive everything and that's why the library comes with a StorePublisher that will provide a stream of changes to the state. With that you can use TCA in UIKit for example, and is very easy to use. But sadly, there is no affordance to observe the actions themselves.

The problem with observing state

Observing the state is totally the right thing to do in features that use the TCA, but it comes with some problems when you want to use it to interact with the exterior world. Having to listen to the state means that you need boilerplate to check if the change that you cared actually happened, furthermore you probably will need an action to reset the state once you've done what you wanted with it. These things are not an issue in a fully declarative world but on the ugly outside it's a big annoyance.

But the big issue is how this usually means polluting your state just so something can be observed from the outside. This may seem not that important, but in my opinion is a big negative point for this solution.

What we really care in these cases are the events that happen inside the feature. In other words, we need to know what actions are passing through the Store.

Relay

The goal then is to hook into the Store to get the actions, but we want to do that without modifying the library. With that limitation, there is only one place we can use to get callbacks when actions are sent, and that's the reducer.

This means that we need to extend the reducer we use for the specific feature on the fly. Something like this would work:

let store = Store(
        initialState: ...,
        reducer: featureReducer.combined(with: .init({ _, action, _ in
                // switch on the action that you want to listen to and run some code
                case .someAction:
                      runSomeCallback()
                      return .none
            }))
...

This works fine and is all you need, but is worth discussing the disadvantages of this approach.

If you look at it with a TCA mindset, you immediately see how we're performing uncontrolled side effects. In a way, it makes sense. That's exactly what we wanted after all! But my fear when I was approaching this was mainly from a teaching point of view. The rest of my team was still learning the TCA, and I was afraid that looking at code like this would pollute their minds.

To cleanup this I followed a good advice and made a high order reducer explicitly for this. This high order reducer gives an explicit name to this operation, relay. Besides this, it makes the closure cleaner since the only thing it receives is the action and it doesn't have to return any Effect. This may feel like a minor detail, but making the closure look different from a normal reducer makes it more clear that what you are doing in the closure has nothing to do with TCA.

So the result looks like this:

let store = Store(
        initialState: ...,
        reducer: featureReducer.relay { action in
          switch action {
            case .someAction:
              runSomeCallback()
          }
        },
  ...

Much nicer!

Thanks to this, we can easily integrate TCA features with the rest of the application that relies in a more typical architecture.

You can read my original GitHub discussion on the topic.

RelayStore

The relay method works well when you create the store in a class (a UIViewController, a Coordinator...) but it breaks down very quickly when you want to use it in SwiftUI.

For example, if you want to use this in a pure SwiftUI App you may start with this code:

@main
struct FeaturePreviewApp: App {
    let store = Store(
        initialState: .init(),
        reducer: featureReducer,
        environment: .init(...)
   )
   var body: some Scene {
        WindowGroup {
            FeatureView(store: store)
        }
    }
}

You may wonder what's the point of this, why not use the TCA fully then. The answer is "Preview Apps". We have tiny apps that run only a specific feature. We don't want to overcomplicate the code in the app itself and we want to use a SwiftUI app since they require so little setup.

Then you may add a local state gets assigned when something happens in the feature. That local state will drive an alert so you can show that something happened on the preview app.

@main
struct FeaturePreviewApp: App {
  let store = Store(
      initialState: .init(),
      reducer: featureReducer,
      environment: .init(...)
  )
  @State var someLocalStateVariable: String?

  var body: some Scene {
      WindowGroup {
          FeatureView(store: store)
      }
      .alert(isPresented: $someLocalStateVariable.hasValue(), content: {
          Alert(title: Text(someLocalStateVariable!))
      })
  }
}

Now it's time to use the relay...

   let store = Store(
      initialState: .init(),
      reducer: featureReducer.relay { action in
        switch action {
          case let .something(value):
          	someLocalStateVariable = value // NOPE!
          ...
        }
      },
      environment: .init(...)
  )
  @State var someLocalStateVariable: String?


As soon as you try to access the local state, the compiler will complain that you are trying to access self too soon. And since this is a struct you can't use the trick of making store a lazy var.

What you need is to use the relay in the body of the View. But if you try to move the store to it, you will end up with issues since SwiftUI will recompute the body of the View and that will recreate the entire store!

   var body: some Scene {
      WindowGroup {
          FeatureView(store: Store( // store will be created on every sate change!
            initialState: .init(),
            reducer: featureReducer.relay { action in
              switch action {
                case let .something(value):
                  self.someLocalStateVariable = value // this is fine now
                ...
              }
            },
            environment: .init(...)
        ))
      }

So we need the Store as a local property but the relay on the body... what a conundrum.

The solution to the problem is what I call the RelayStore, not only is not very original but is also a bit of a lie since is not a Store 🙈.

With it, you can write exactly what you need:

@main
struct FeaturePreviewApp: App {
  let relayStore = RelayStore( // local property 👍
      initialState: .init(),
      reducer: featureReducer,
      environment: .init(...)
  )
  @State var someLocalStateVariable: String?

  var body: some Scene {
      WindowGroup {
          FeatureView(store: relayStore.storeWithRelay { action in
            switch action {
                case let .something(value):
                	self.someLocalStateVariable = value // access to self 👍
                ...
              }
          })
      }
      .alert(isPresented: $someLocalStateVariable.hasValue(), content: {
          Alert(title: Text(someLocalStateVariable!))
      })
  }
}

The trick here is that this fake store doesn't initialise the real store on the init. Instead, it just keeps the parameters (initial state, reducer and environment) as local properties. It's only when you call storeWithRelay where a real store is created, using the injected closure as the relay to extend the reducer and, very importantly, keep that real store around so is not recreated in the next refresh.

Conclusion

The TCA is superb, but it has a shortcoming when you want to observe the actions of the Store to integrate it with an external non declarative system. The relay solution is decent and I like it, but it needed a bit of experimentation and help from the RelayStore to get the polish it needed.

You can find the code for this in the RelayStore GitHub repo.

If you liked this article please consider supporting me