The Composable Architecture (TCA)

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article will discuss a framework that is perfectly suited for creating complex SwiftUI applications - The Composable Architecture (TCA). It will cover its features and advantages, recent developments, considerations when using it, and learning path.

Introduction to TCA

The Composable Architecture (abbreviated as TCA) allows you to build applications in a unified and easy-to-understand way, taking into account composition, testing, and efficacy. You can use TCA on SwiftUI, UIKit, and other frameworks, and on any of Apple’s platforms (iOS, macOS, tvOS, and watchOS).

TCA provides core tools for building apps for various purposes and complexities. You can follow it step by step to solve many problems you often encounter in daily development, such as:

  • State Management Manage the application state with simple value types, and call these states in different interfaces, so that changes within one interface can be immediately reflected in another interface.
  • Composition Break down complex functions into small, independently runnable components, and then reassemble them into the original functionality.
  • Side Effects Communicate some parts of the app with the outside world in the most testable and understandable way.
  • Testing In addition to testing a feature, you can also integrate test it with other features to create more complex features, and use end-to-end testing to understand how side effects affect your application. This ensures that business logic is in line with expectations.
  • Ergonomics Use a minimum of concepts and movable parts, and a simple API to achieve all of the above.

This article will not further explain the concepts of State, Action, Reducer, and Store.

Features and Advantages of TCA

Powerful Assembly Capability

Since the framework is named The Composable Architecture, it must have its unique capabilities in assembly.

TCA encourages developers to break down large features into small components that use the same development logic. Each component can be unit tested, viewed, and even debugged on a real machine, and by extracting the component code into an independent module, the compilation speed of the project can be further improved.

The so-called assembly is the process of gluing these independent components together into a more complete function according to the preset hierarchy and logic.

The concept of assembly exists in most state management frameworks, and only a small amount of code is required to provide some basic assembly capabilities. However, limited assembly capabilities limit and affect developers’ willingness to split complex functions, and the original intention of assembly has not been thoroughly implemented.

TCA provides a lot of tools to enrich its assembly methods. When developers find that assembly is no longer difficult, they will think about the composition of functions from a smaller granularity at the beginning of development, thereby creating more robust, readable, and scalable applications.

Some tools provided by TCA for assembly:

CasePaths

It can be understood as an enum version of KeyPath.

In other Redux-like frameworks, two separate closures are needed to map actions between different components when assembling parent-child components, for example:

Swift
func lift<LiftedState, LiftedAction, LiftedEnvironment>(
    keyPath: WritableKeyPath<LiftedState, AppState>,
    extractAction: @escaping (LiftedAction) -> AppAction?, // Convert child component's Action to parent component's Action
    embedAction: @escaping (AppAction) -> LiftedAction, // Convert parent Action to child Action
    extractEnvironment: @escaping (LiftedEnvironment) -> AppEnvironment
) -> Reducer<LiftedState, LiftedAction, LiftedEnvironment> {
    .init { state, action, environment in
        let environment = extractEnvironment(environment)
        guard let action = extractAction(action) else {
            return Empty(completeImmediately: true).eraseToAnyPublisher()
        }
        let effect = self(&state[keyPath: keyPath], action, environment)
        return effect.map(embedAction).eraseToAnyPublisher()
    }
}

let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
    childReducer.lift(keyPath: \.childState, extractAction: {
        switch $0 {  // Need to map each child component's Action separately
            case .childAction(.increment):
                return .increment
            case .childAction(.decrement):
                return .decrement
            default:
                return .noop
        }
    }, embedAction: {
        switch $0 {
            case .increment:
                return .childAction(.increment)
            case .decrement:
                return .childAction(.decrement)
            default:
                return .noop
        }
    }, extractEnvironment: {$0}),
    parentReducer
)

CasePaths provides automatic handling of this conversion process, and we only need to define a case in the parent component’s Action that contains the child Action:

Swift
enum ParentAction {
    case ...
    case childAction(ChildAction)
}

let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
  counterReducer.pullback(
    state: \.childState,
    action: /ParentAction.childAction, // Mapping directly through CasePaths
    environment: { $0 }
  ),
  parentReducer
)

IdentifiedArray

IdentifiedArray is a class array type with dictionary features. It has all the functions of an array and similar performance. It requires that the elements in it must conform to the Identifiable protocol and that the id in IdentifiedArray is unique. This way, developers can access data directly through the element’s id in a dictionary-like way without relying on the index.

IdentifiedArray ensures the system stability when splitting a sequence property in the parent component’s state into independent sub-component states. It avoids abnormal situations or even application crashes caused by modifying elements using index.

This way, developers will be more confident in splitting sequence states and the operations will be more convenient.

For example:

Swift
struct ParentState:Equatable {
    var cells: IdentifiedArrayOf<CellState> = []
}

enum ParentAction:Equatable {
    case cellAction(id:UUID,action:CellAction) // Create a case on the parent component to map child actions, using the element's id as an identifier
    case delete(id:UUID)
}

struct CellState:Equatable,Identifiable { // Elements conform to the Identifiable protocol
    var id:UUID
    var count:Int
    var name:String
}

enum CellAction:Equatable{
    case increment
    case decrement
}

let parentReducer = Reducer<ParentState,ParentAction,Void>{ state,action,_ in
    switch action {
        case .cellAction:
            return .none
        case .delete(id: let id):
            state.cells.remove(id:id) // Operate on IdentifiedArray in a dictionary-like way to avoid index errors or out-of-range situations
            return .none
    }
}

let childReducer = Reducer<CellState,CellAction,Void>{ state,action,_ in
    switch action {
        case .increment:
            state.count += 1
            return .none
        case .decrement:
            state.count -= 1
            return .none
    }
}

lazy var appReducer = Reducer<ParentState,ParentAction,Void>.combine(
    //
    childReducer.forEach(state: \.cells, action: /ParentAction.cellAction(id:action:), environment: { _ in () }),
    parentReducer
)

// In the view, ForEachStore can be used directly for splitting
ForEachStore(store.scope(state: \.cells,action: ParentAction.cellAction(id: action:))){ store in
    CellVeiw(store:store)
}

WithViewStore

In addition to the various assembly and splitting methods applied to Reducer and Store, TCA also provides a tool specifically for further subdivision within SwiftUI views - WithViewStore.

WithViewStore allows developers to further control the state and actions of the current view, improving code purity and reducing unnecessary view refreshes to some extent, thus improving performance. For example:

Swift
struct TestCellView:View {
    let store:Store<CellState,CellAction>
    var body: some View {
        VStack {
            WithViewStore(store,observe: \.count){ viewState in // Only observe changes in count. Even if the name property in cellState changes, this view will not be refreshed.
                HStack {
                    Button("-"){viewState.send(.decrement)}
                    Text(viewState.state,format: .number)
                    Button("-"){viewState.send(.increment)}
                }
            }
        }
    }
}

There are many similar tools, please refer to the official TCA documentation for more information.

Complete Side Effect Management Mechanism

In practical applications, it is impossible to require all reducers to be pure functions, and operations such as data storage, retrieval, network connection, logging, etc. will be regarded as side effects (called Effects in TCA).

For side effects, the framework mainly provides two services:

  • Dependency Injection

    Before version 0.41.0, the way TCA injected external environments was similar to most other frameworks and there was nothing special about it. However, in the new version, there have been significant changes in the way dependency injection is done, which will be explained in more detail below.

  • Wrapping and managing side effects

    In TCA, after handling any Action, the Reducer needs to return an Effect, and developers can form an Action chain by generating or returning new Actions within the Effect.

    Before version 0.40.0, developers needed to wrap the side effect handling code into a Publisher in order to convert it into an Effect that TCA can accept. Starting from version 0.40.0, we can use some preset Effect methods (run, task, fireAndForget, etc.) directly with asynchronous code based on async/await syntax, greatly reducing the cost of wrapping side effects.

    In addition, TCA also provides many preset Effects to facilitate developers in dealing with scenarios that involve complex and large amounts of side effects, such as timer, cancel, debounce, merge, concatenate, etc.

In short, TCA provides a comprehensive mechanism for managing side effects, and with only a small amount of code, different scenario requirements can be addressed within the Reducer.

Convenient Testing Tool

Compared to its performance in assembly, TCA’s attention and support for testing is another major feature that sets it apart from other small and medium-sized frameworks.

In TCA or similar frameworks, side effects are run asynchronously. This means that if we want to test the complete functionality of a component, it is usually unavoidable to involve asynchronous operation testing.

For Redux-like frameworks, developers usually do not need to perform actual side effect operations when testing functional logic. They only need to ensure the logic of Action -> Reducer -> State runs accurately.

To this end, TCA provides a TestStore type specifically for testing and a corresponding DispatchQueue extension. With TestStore, developers can perform operations such as sending Action, receiving mock Action, and comparing State changes on a virtual timeline. This not only stabilizes the testing environment but also, in some cases, can convert asynchronous testing to synchronous testing, thereby greatly reducing testing time. For example (the following code is written using the Protocol method of version 0.41.0):

Swift
struct DemoReducer: ReducerProtocol {
    struct State: Equatable {
        var count: Int
    }

    enum Action: Equatable {
        case onAppear
        case timerTick
    }

    @Dependency(\.mainQueue) var mainQueue // Inject dependencies

    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                return .run { send in
                    while !Task.isCancelled {
                        try await mainQueue.sleep(for: .seconds(1)) // Use the queue provided by the dependency for convenient testing
                        await send(.timerTick)
                    }
                }
            case .timerTick:
                state.count += 1
                return .none
            }
        }
    }
}

@MainActor
final class TCA_DemoReducerTests: XCTestCase {
    func testDemoStore() async {
        // Create TestStore
        let testStore = TestStore(initialState: DemoReducer.State(count: 0), reducer: DemoReducer())
        // Create a test queue. TestSchedulerOf<DispatchQueue> is a DispatchQueue extension provided by TCA for convenient unit testing with time adjustment function
        let queue = DispatchQueue.test
        testStore.dependencies.mainQueue = queue.eraseToAnyScheduler() // Change to test dependency
        let task = await testStore.send(.onAppear) // Send onAppear Action
        await queue.advance(by:.seconds(3))  // Advance time by 3 seconds (does not occupy 3 seconds of time during testing and is performed synchronously)
        _ = await testStore.receive(.timerTick){ $0.count = 1} // Receive 3 timerTick Actions and compare State changes
        _ = await testStore.receive(.timerTick){ $0.count = 2}
        _ = await testStore.receive(.timerTick){ $0.count = 3}
        await task.cancel() // End task
    }
}

The above code allows us to test a unit test that originally required three seconds to obtain results without waiting.

In addition to TestStore, TCA also provides XCTUnimplemented (declaring unimplemented dependency methods), several new assertions for testing, and the SnapshotTesting tool for developers to easily create screenshots.

In this way, developers will be able to build more complex and stable applications using TCA.

Active Community and Comprehensive Resources

TCA is currently the most popular framework of its kind developed in Swift language. As of the writing of this article, TCA has reached 7.2K stars on GitHub. It has a very active community where problems are quickly addressed and resolved.

TCA emerged from the video courses of Point Free, which has a considerable amount of video content related to TCA. This includes the problems faced in current development, solutions, planning, implementation details, and more. There are hardly any other frameworks with such comprehensive companion content. These contents not only promote TCA but also allow developers to gradually understand and master all aspects of TCA, making it easier to contribute to the TCA community. The two have played a very good mutual promotion role.

Latest Changes in TCA (from 0.40.0)

Recently, TCA has undergone two significant upgrades (0.40.0 and 0.41.0). This section will introduce some of the upgrade contents.

Better Asynchronous Support

Prior to version 0.40.0, developers needed to wrap side effects in Publishers. This not only resulted in more code, but also made it difficult to use the increasing number of APIs based on the async/await mechanism. With this update, developers can use these modern APIs directly in the Effect of the Reducer. This not only reduces code, but also allows developers to benefit from Swift’s better thread coordination mechanism.

By using SwiftUI’s task modifier, TCA manages the lifecycle of Effects that require long-running tasks automatically.

Since onAppear and onDisappear may appear multiple times during the view’s lifetime, the lifecycle of the Effect maintained by task may not necessarily be consistent with the view.

For example, the following code will be more clear and natural after version 0.40.0:

Swift
// Old version
switch action {
  case .userDidTakeScreenshotNotification:
    state.screenshotCount += 1
    return .none

  case .onAppear:
    return environment.notificationCenter
      .publisher(for: UIApplication.userDidTakeScreenshotNotification)
      .map { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification }
      .eraseToEffect()
      .cancellable(id: UserDidTakeScreenshotNotificationId.self)

  case .onDisappear:
    return .cancel(id: UserDidTakeScreenshotNotificationId.self)
  }

// in View

Text("Hello")
    .onAppear { viewStore.send(.onAppear) }
    .onDisappear { viewStore.send(.onDisappear) }

Using Task mode:

Swift
 switch action {
    case .task:
      return .run { send in
        for await _ in await NotificationCenter.default.notifications(named: UIApplication.userDidTakeScreenshotNotification).values { // read from AsyncStream
          await send(.userDidTakeScreenshotNotification)
        }
      }

    case .userDidTakeScreenshotNotification:
      state.screenshotCount += 1
      return .none
    }
  }

// in View
Text("Hello")
    .task { await viewStore.send(.task).finish() } // automatically ends when onDisappear is called

On the other hand, TCA cleverly wraps the return value of Task in a new TaskResult type, similar to the Result mechanism. This allows users to handle errors in the Reducer without using the previous catch method.

Reducer Protocol - Writing Reducers with a Declarative View

Starting from version 0.41.0, developers can declare Reducers using the new Reducer Protocol (as shown in the code displayed in the testing tool), and can introduce dependencies across different levels of Reducers using Dependency.

The Reducer Protocol provides the following benefits:

  • Easier-to-understand logic definition

    Each feature has its own namespace, which includes the required State, Action, and introduced dependencies. The code organization is more reasonable.

  • More friendly IDE support

    Before using the Protocol mode, Reducers were generated through a closure with three generic parameters. In this mode, Xcode’s code completion feature does not work, and developers can only write code by memory, which is inefficient. With Reducer Protocol, since all required types are declared in one namespace, developers can make full use of Xcode’s auto-completion to efficiently develop.

  • Similar declaration mode to SwiftUI Views

    The Reducer assembly mechanism has been refactored using the result builder, allowing developers to declare Reducers in the same way as declaring SwiftUI Views, which is more concise and intuitive. By adjusting the composition angle of Reducers, the way of pulling back child Reducers to parent Reducers has been modified to scoping child Reducers on parent Reducers. This not only makes it easier to understand, but also avoids some common assembly errors caused by incorrect placement order of parent-child Reducers.

  • Better Reducer performance

    The new declaration method is more friendly to the Swift language compiler and will enjoy more performance optimizations. In practice, the call stack created using the Reducer Protocol for the same Action is shallower.

  • More complete dependency management

    A new DependencyKey method is used to declare dependencies (very similar to SwiftUI’s EnvironmentKey), which allows dependencies to be introduced across Reducer levels like EnvironmentValue. In DependencyKey, developers can simultaneously define implementations for live, test, and preview scenarios, further simplifying the need to adjust dependencies in different scenarios.

Notes

Learning Cost

Like other frameworks with powerful features, the learning cost of TCA is not low. Although it doesn’t take too much time to understand how to use TCA, it’s difficult to write satisfactory code if developers cannot truly master its internal assembly logic.

Although TCA seems to provide a bottom-up development approach for developers, if the complete functionality is not well thought out, it will be found impossible to assemble the desired effect.

TCA requires higher abstraction and planning capabilities from developers, and it is important to remember not to simply learn and then invest in the production practice of complex requirements.

Performance

In TCA, State and Action are required to comply with the Equatable protocol, and like many Redux-like solutions, TCA cannot provide support for reference value type states. This means that in some scenarios where reference types must be used, if you still want to maintain the logic of a single State, you need to convert the reference types into value types, which will result in some performance loss.

In addition, the mechanism of using WithViewStore to focus on specific properties internally uses Combine. When there are many levels of Reducers, TCA also needs to pay a significant cost for splitting and comparison. Once the cost it pays exceeds the optimization result, performance issues will occur.

Finally, TCA is still unable to cope with high-frequency Action calls. If your application may generate high-frequency Action (several dozen times per second), then you need to limit or adjust the event source. Otherwise, there will be a situation where the status is not synchronized.

How to Learn TCA

Although TCA largely reduces the chance of using other dependencies (compliant with the DynamicProperty protocol) in views, developers should still have a deep understanding and mastery of the native dependency solutions provided by SwiftUI. On the one hand, in many lightweight developments, we don’t need to use such heavyweight frameworks. On the other hand, even when using TCA, developers still need to use these native dependencies as supplements to TCA. This has been fully demonstrated in the CaseStudies code provided by TCA.

If you are a beginner in SwiftUI and have little knowledge of Redux or Elm, you can first try using some lighter Redux-like frameworks. After becoming familiar with this development mode, you can then learn TCA. I recommend that you can read Majid’s series of articles about Redux-like.

Wang Wei’s series of articles on TCA - TCA - The Savior of SwiftUI? is also an excellent introductory material, which I suggest interested developers of TCA to read.

TCA provides a lot of sample code, from the simplest reducer creation to the fully functional published application. These sample codes also continue to change with the version updates of TCA, many of which have been refactored using the Reducer Protocol.

Of course, if you want to learn the latest and most in-depth content about TCA, you still need to watch the video courses on the Point Free website. These video courses provide complete text versions and corresponding code, so even if your listening skills are limited, you can understand all the content through the text version.

Summary

According to the plan, TCA will soon replace the remaining Combine code (Apple’s closed-source code) with async/await code. This will make it possible to become a framework that supports multiple platforms. Perhaps TCA will have the opportunity to be ported to other languages in the future.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed