TCA Action Boundaries

I've been using The Composable Architecture for almost two years, and I'm currently working on the 5th Application leveraging this architecture, The Browser Company. Arc is one of the largest applications leveraging TCA. As such, we have a lot of features and reducers composed together.

As I described in the exhaustivity testing article, a larger scale usually means discovering issues you might not have experienced with smaller apps. I'll cover more of them and my suggested solutions shortly, but today, I want to talk about Actions, their lack of boundaries, and what it entails.

Actions

In TCA, we define actions as enums:

enum MyFeatureAction: Equatable {
  case onDidTapLogin
  case onDidTapLogout
  case onPullToRefresh
  
  case loginResults(Result<DataFetch, Error>)
  case scheduleTimer
}

We use them in reducer functions, e.g.

switch action {
  case .onDidTapLogin:
    return env.loginUser(state.email, password: state.password)
      .map(MyFeatureAction.loginResults)
}
Feature Reducer

With TCA, it's possible to both observe and schedule actions in parent reducers, so an appReducer could be listening to onDidTapLogin action from child feature.

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { action, state, env in
   switch action {
     case .myFeatureAction(.onDidTapLogin):
       state.isLoggingIn = true
     return .none
   }
}
App Reducer

This approach can be helpful in building higher-order reducers like the default debug operator or my own debugDiffing or to create a single analytics reducer for the whole app.

But in larger applications, it can easily lead to complicated code and hard-to-understand bugs.

The Problem

In Unidirectional Data Flow architectures, the State should be the source of truth. Actions should be treated like functions in regular OOP programming. The problem is Enums in Swift doesn't have any access control.

When we observe our modules' actions in the higher order reducers, we look at the implementation detail of those features, which many would consider an anti-pattern.

Scenario

Let's say we initially implemented a feature action like .onHovered(URL) And we use that in our Feature View to present an overlay somewhere in the Application. We also listen to that internal action to update the status bar in our Application (just like standard browsers do).

Non-Exhaustive parent integration

Now because we usually will have plenty of actions in MyFeatureAction, the parent integration usually only looks into a specific action or use default case:

switch action {
  case let .myFeatureAction(.onHovered(url)):
    state.statusBar = url.string
    return .none
}
Parent Reducer

or

switch action {
  case let .myFeatureAction(action):
  switch action {
    case let .onHovered(url):
      state.statusBar = url.string
      return .none
    default: return .none
  }
}
Alternative Parent Reducer

Both approaches mean we don't compiler help when actions get extended because we aren't using exhaustive integration.

Extending functionality

Later on, someone gets tasked to extend that feature, and they add a new action onHovered(URL, Context) which carries some additional information.

Suppose the engineer implementing that feature is not aware of something observing the original action. In that case, it's easy to miss adding support for this alternative version, and now we have inconsistent behavior in our Application. After all, that action was an internal implementation detail of the feature. It wasn't clear that it was used somewhere else.

In larger apps, it's too easy not to be aware of side effects. We should leverage compile time errors whenever possible.

Not everything should become a State

On the other hand, there are situations where we'd want to know when something happened in the embedded module. Back in the old days, we'd use the Delegate pattern.

Three kinds of actions

In most features, I usually have three distinct types of actions:

  1. The user does something in the UI Layer
  2. Internal action I need to call from the reducer, e.g. loginResults
  3. The event I want the higher order reducers to be aware of

For long-term maintenance of your projects, if you are not going to establish stronger API boundaries (more on this in the future articles), it's worth introducing a convention to improve readability and possibly write linter rules for your codebase.

The convention I've settled on in my projects:

enum MyFeatureActions: Equatable {
  enum Delegate: Equatable {
    case userLoggedIn
  }

  case onDidTapLogin // User Action
  case _someInternalAction // Internal Action
  case delegate(Delegate)  // Delegate Action
}

Delegate Actions

I've decided to embed delegate actions into its sub-enum because this allows me to bind the reducers that are embedding my feature exhaustively:

let parentReducer = ... { action, state, env in 
  switch action {
    case let myFeatureAction(.delegate(action)):
      switch action {
        case userLoggedIn:
          state.isLoggedIn = true
          return .none
      }
     ...
  }
}
Parent Reducer

Suppose my delegate action enum gets extended in the future. In that case, the compiler will tell me when I need to handle it, ensuring more safety and fewer bugs creeping into my codebase.

I sent those actions explicitly, never through UI, e.g.

switch action {
  case .loginResults:
    ...
    return .init(value: .delegate(.userLoggedIn))
}
Feature Reducer

Internal Actions

I prepend all my internal actions with an underscore, and I can write linter rules that if they ever appear in my view layer or higher reducers, I'll fail to compile the project.

User Actions

Those are just your standard TCA actions that are sent from View Layer.

Update (22/08):

After I wrote the original article Ian mentioned he packs it even more and after playing with it for a few days I now prefer to pack view-related actions under a ViewAction sub-enum, allowing me to scope  View layer to only interact with those, it also gives us better code-completion:

So the final form of what I'm going to use going forward looks like this:

public protocol TCAFeatureAction {
    associatedtype ViewAction
    associatedtype DelegateAction
    associatedtype InternalAction

    static func view(_: ViewAction) -> Self
    static func delegate(_: DelegateAction) -> Self
    static func internal(_: InternalAction) -> Self
 }
 
 public enum MyFeatureAction: TCAFeatureAction {
   enum ViewAction: Equatable {
      case didAppear
      case toggle(Todo)
      case dismissError
   }

   enum InternalAction: Equatable {
     case listResult(Result<[Todo], TodoError>)
     case toggleResult(Result<Todo, TodoError>)
   }

   enum DelegateAction: Equatable {
     case ignored
   }

  case view(ViewAction)
  case internal(InternalAction)
  case delegate(DelegateAction)
}

Conclusion

To make it easier to maintain our codebases for years to come, it's important to establish boundaries across modules of our apps.

It's my preference to treat each module as a Black Box with explicit Input and Outputs, but in the case of The Composable Architecture with a single Store, due to the fact that Actions are public, that's not really a viable option.

For that reason I highly recommend going with a consistent convention-based approach and adding some custom linter rules to maintain it, this will pay dividends in years to come as your projects grow and you need to keep maintaining and evolving them.

You've successfully subscribed to Krzysztof Zabłocki
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.