The team at The Browser Company is a major adopter of The Composable Architecture (TCA) framework. Based on our team’s experiences and insights from the wider community, I’ve developed a new set of best practices that can benefit your TCA projects.
Here are some of the key practices to consider using in your projects.
Reducers
-
To adhere to the boundaries approach, it’s best to name your view actions based on “what happened” rather than their expected effect. For instance, you should use
.didTapIncrementButtoninstead of.incrementCount. This approach enables you to add logic to the action while still keeping it true to its name. Additionally, this practice encourages keeping business logic in the reducer instead of letting it slip into the view layer. -
Try to avoid performing expensive operations in Reducers.
-
Reducers run on the main thread, and such operations can cause lag or even freeze the UI. Instead, consider leveraging
.task/EffectTaskand environment clients, which allow you to perform these operations off the main thread. By doing so, you can ensure that your app remains responsive and performant.return .task {
.reducer(.onResultsUpdated(await self.search(query: query))
}
-
When nilling out state, it’s important to unregister any long-running effects to prevent memory leaks and bugs. To do this, you can use the
.cancellablemethod to cancel any ongoing effects before the state is deallocated. By doing so, you can ensure that your app remains memory-efficient and stable. -
When using
concatenateandmergemethods, it’s important to consider their differences. Theconcatenatemethod will wait for previous effects to complete before running the next ones, while themergemethod runs effects in parallel. However, it’s important to note that relying onconcatenatecan become problematic when using other actions that might introduce delays in the future, such as animations. This can also delay any following effects. Therefore, it’s often better to use the merge method to ensure that effects run in parallel and prevent delays that could impact the user experience. -
Avoid high-frequency actions like Timers hitting your reducer to check something. Instead, perform the work in the tasks or env clients and only send back actions when actual work on State needs to be performed.
-
An example might be mouse move handlers. When we track mouse movement, we often do so, waiting for some condition to be true. Best to check that condition in the views code and then only pipe the edge condition/events into TCA.
-
**Don’t use actions for sharing logic.**They are not methods.
-
Sending actions is not as lightweight as calling a method on a type.
-
Each (async) action causes rescoping and equality checks across our application
-
Only exception for now:
.delegate()actions -
Instead, use mutating methods on State objects
-
Currently exploring the alternative of extending
ReducerProtocolimplementations with functions that takeinoutState variable. This allows accessing theReducers dependencies without passing them into helper methods. -
When possible, it’s best to use feature state instead of projected state (computed properties) in your app. It can help you avoid unnecessary computation and improve your app’s performance.
-
For instance, in your AppReducer, it’s better to use
localWindowsinstead of thewindowscomputed property, if available. By doing so, you can avoid computing the windows property every time it’s accessed, reducing the load on the main thread and improving overall app performance. -
When using
onChangereducers, it’s important to be careful and mindful of where you add them in the reducer composition. This is because onChange reducers only work at a specific level of the reducer composition. -
If you’re experiencing issues with
onChangereducers not working, it’s likely that they are added at the wrong level of reducer. For example, you may be observing at the Feature level, but the mutation occurs at the App level. To resolve this issue, make sure that you add the onChange reducers at the appropriate level of reducer composition to ensure that they function as intended. -
When observing child feature actions, use delegate actions from our boundaries convention.
State Modeling
-
Be extra careful with code inside the
scopefunctions, e.g., computing child state. -
Those calls happen for every action sent to the system, so they must be fast.
-
Try to avoid calculations. Even O(n) complexity will cause issues in hot paths for large sidebars.
-
Either pre-compute heavy data or make the view layer calculate it if needed
-
Be especially careful when using computed properties, as they can be expensive due to the same problems as normal scope. Other than being in the hot-path, they often might re-create objects each time they are called.
-
Make state optional when possible and leverage
ifLetandoptionalpullbacks to avoid performing unnecessary work. -
E.g., The command bar was not optional, so its state/reducer and view layer side effects would run even when it wasn’t visible.
-
UI State doesn’t always need to persist.
-
E.g., sidebar hover used to be a state property, but it only needs to live at View Layer.
-
Be careful when updating persisted state objects. They might require migrations. Make sure to add tests to avoid user data loss.
-
Avoid referring to
userDefaultsin scoping function. They should use the currentStateand not refer to anything else. -
Projected state initializers should be as fast as possible, usually just initializers without any computations.
Testing
-
Use
prepareDependenciesand always useinitialStateexplicitly -
That allows state initializers to leverage test
@Dependencyvalues e.g. UUID generator.// Don’t
let windows = WindowsState.stub(windows: [.init(window)], sidebar: .stub(globalSidebarContainer: sidebar))
let store = TestStore(
initialState: windows,
prepareDependencies: {
$0.uuid = .incrementing
}
)
// Do
let store = TestStore(
initialState: .stub(windows: ...),
prepareDependencies: {
$0.uuid = .incrementing
}
)
Dependencies
- Use structs for clients with mutable
varinstead ofprotocols
Protocol-oriented interfaces for clients are discouraged in our codebase
// Don't
protocol AudioPlayer {
func loop(_ url: URL) async throws
func play(_ url: URL) async throws
func setVolume(_ volume: Float) async
func stop() async
}
// Do
struct AudioPlayerClient {
var loop: (_ url: URL) async throws -> Void
var play: (_ url: URL) async throws -> Void
var setVolume: (_ volume: Float) async -> Void
var stop: () async -> Void
}
This allows us to describe the bare minimum of the dependency in tests. For example, suppose that one user flow of the feature you are testing invokes the play endpoint, but you don’t think any other endpoint will be called. Then you can write a test that overrides only one endpoint and uses the default .failing version for all the others. By doing so, you can ensure that your tests are focused and efficient, making it easier to identify and resolve any issues that arise.
let model = withDependencies {
$0.audioPlayer.play = { _ in await isPlaying.setValue(true) }
} operation: {
FeatureModel()
}
-
Use placeholders in
unimplementedfailing stubs where the endpoints returns a value.
This allows your test suite to continue running even if a failure occurs. If an issue is detected, it’s reported via XCTFail along with the name of the endpoint that caused the failure. This is preferable to a fatalError, which can cause the test to crash and drop into the debugger, interrupting the rest of the test suite.
// DON'T
searchHistory: unimplemented("\(Self.self).searchHistory")
// DO
searchHistory: unimplemented("\(Self.self).searchHistory", placeholder: [])
View layer
-
Use the new
observe:``ViewStateinitializer as it forces you to createViewState// DON’T
viewStore = .init(store.scope(state: \.overlayViewState))
// DO
viewStore = .init(store, observe: \.overlayViewState)
-
No need to create a
ViewStoreif you only need to send one-off actions, e.g.,ViewStore(store.stateless).send(.action) -
Note that we use
.statelessvariant, which skips tempViewStorefrom taking part in the update pipeline. -
Always scope down your
ViewStorescopes to the minimal amount yourViewneeds by introducing localViewStatefor it, as this avoids unnecessary diffing and view reloads -
Either only add actively observing properties to ViewState or consider creating multiple different ViewStores for more complex use-cases
-
Besides a view-lifecycle action (e.g.
viewDidAppear, or even better, bind it to the lifetime of the view via a task), there should be no other action sent into the store on appear or load. Especially for tableview cells, this can lead to a ton of actions being sent into the store on appearance, e.g.onHover(false) -
Mind that subscribing to a
ViewStore’s publisher triggers synchronously. So when in AppKit, consider adding adropFirstwhen subscribing to viewStore changes, in case it’s only about reacting to changes.
Bugs & Workarounds
- If you break auto-completion in
ReducerProtocolbodies, the workaround is to add explicit type to your definitions, e.g.,Reduce<State, Action> { - If the same or compiler errors happen when using
WithViewStoreeither add explicit type in the body, e.g.,WithViewStore(self.store) { (viewStore: ViewStoreOf<Feature> in)or introduce@ObservedObject var viewStore: ViewStoreOf<Feature>instead of theWithViewStorewrapper.
Conclusion
The Browser Company is a major adopter of The Composable Architecture (TCA) framework. We have shared best practices based on our team’s experiences and insights from the wider community.
These practices are designed to help you optimize the performance and stability of your TCA projects and make them more maintainable in the long term. By adopting these practices, you can build high-quality TCA projects that meet your users’ needs and exceed their expectations.
References: