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.
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.
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:
or
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:
- The user does something in the UI Layer
- Internal action I need to call from the reducer, e.g.
loginResults
- 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:
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.
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.