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
.didTapIncrementButton
instead 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
/EffectTask
and 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.
- Reducers run on the main thread, and such operations can cause lag or even freeze the UI. Instead, consider leveraging
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
.cancellable
method 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
concatenate
andmerge
methods, it's important to consider their differences. Theconcatenate
method will wait for previous effects to complete before running the next ones, while themerge
method runs effects in parallel. However, it's important to note that relying onconcatenate
can 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
ReducerProtocol
implementations with functions that takeinout
State variable. This allows accessing theReducer
s dependencies without passing them into helper methods.
- Currently exploring the alternative of extending
- 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
localWindows
instead of thewindows
computed 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.
- For instance, in your AppReducer, it's better to use
- When using
onChange
reducers, 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
onChange
reducers 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.
- If you're experiencing issues with
- When observing child feature actions, use delegate actions from our boundaries convention.
State Modeling
- Be extra careful with code inside the
scope
functions, 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.
- Those calls happen for every action sent to the system, so they must be fast.
- Make state optional when possible and leverage
ifLet
andoptional
pullbacks 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
userDefaults
in scoping function. They should use the currentState
and not refer to anything else. - Projected state initializers should be as fast as possible, usually just initializers without any computations.
Testing
- Use
prepareDependencies
and always useinitialState
explicitly- That allows state initializers to leverage test
@Dependency
values e.g. UUID generator.
- That allows state initializers to leverage test
// 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
var
instead 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
unimplemented
failing 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:
ViewState
initializer 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
ViewStore
if you only need to send one-off actions, e.g.,ViewStore(store.stateless).send(.action)
- Note that we use
.stateless
variant, which skips tempViewStore
from taking part in the update pipeline.
- Note that we use
- Always scope down your
ViewStore
scopes to the minimal amount yourView
needs by introducing localViewState
for 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 adropFirst
when subscribing to viewStore changes, in case it's only about reacting to changes.
Bugs & Workarounds
- If you break auto-completion in
ReducerProtocol
bodies, the workaround is to add explicit type to your definitions, e.g.,Reduce<State, Action> {
- If the same or compiler errors happen when using
WithViewStore
either add explicit type in the body, e.g.,WithViewStore(self.store) { (viewStore: ViewStoreOf<Feature> in)
or introduce@ObservedObject var viewStore: ViewStoreOf<Feature>
instead of theWithViewStore
wrapper.
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: