The Composable Architecture - Best Practices

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.
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 and merge methods, it's important to consider their differences. The concatenate method will wait for previous effects to complete before running the next ones, while the merge method runs effects in parallel. However, it's important to note that relying on concatenate 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 take inout State variable. This allows accessing the Reducers 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 localWindows instead of the windows 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.
  • 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.

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.
  • Make state optional when possible and leverage ifLet and optional 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 current State 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 use initialState explicitly
    • That allows state initializers to leverage test @Dependency values 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 var instead of protocols
    • 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 create ViewState
// 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)
  • Always scope down your ViewStore scopes to the minimal amount your View needs by introducing local ViewState 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 a dropFirst 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 the WithViewStore 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:

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.