12.5 — The Composable Architecture (TCA)

Opening scenario

A mid-level engineer asks: “Everyone at this Swift conference is using TCA. Should we?” Honest answer: maybe. TCA from Point-Free is a powerful, opinionated, unidirectional architecture. It solves real problems — and adds real cost. This chapter is your decision framework, with enough code to start a feature.

Context — TCA at a glance

ConceptRole
StateImmutable value type capturing everything the feature displays
ActionEnum cataloguing every event (user tap, network response, timer fire)
ReducerPure function (inout State, Action) -> Effect<Action>
EffectAnything async (network, timer, dependency call); returns more Actions
StoreOwns state, runs reducer, dispatches effects
TestStoreLets you assert every state transition + effect during tests
DependenciesCompile-time-verified injection (their @Dependency macro)

The mental model is Redux for Swift, with first-class async, first-class effects, and Apple-shaped ergonomics via ViewStore and SwiftUI integration.

Concept → Why → How → Code

Concept: every change to your screen is an Action; the only way to mutate state is via a pure reducer; side effects return more actions, which the reducer handles in turn.

Why: total time-travel debuggability, deterministic tests, exhaustive assertions, navigation as state.

How (modern TCA Reducer macro):

import ComposableArchitecture
import SwiftUI

@Reducer
struct CounterFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
        var isLoadingFact = false
        var fact: String?
    }

    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
        case getFactButtonTapped
        case factResponse(Result<String, any Error>)
    }

    @Dependency(\.numberFact) var numberFact

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none

            case .decrementButtonTapped:
                state.count -= 1
                return .none

            case .getFactButtonTapped:
                state.isLoadingFact = true
                state.fact = nil
                return .run { [count = state.count] send in
                    await send(.factResponse(Result { try await numberFact.fetch(count) }))
                }

            case .factResponse(.success(let text)):
                state.isLoadingFact = false
                state.fact = text
                return .none

            case .factResponse(.failure):
                state.isLoadingFact = false
                state.fact = "Couldn't load fact."
                return .none
            }
        }
    }
}

struct CounterView: View {
    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack(spacing: 16) {
            Text("\(store.count)").font(.largeTitle)
            HStack {
                Button("-") { store.send(.decrementButtonTapped) }
                Button("+") { store.send(.incrementButtonTapped) }
            }
            Button("Number fact") { store.send(.getFactButtonTapped) }
            if store.isLoadingFact { ProgressView() }
            if let fact = store.fact { Text(fact).padding() }
        }
    }
}

Dependency client

import Dependencies

struct NumberFactClient {
    var fetch: (Int) async throws -> String
}

extension NumberFactClient: DependencyKey {
    static let liveValue = NumberFactClient { number in
        let (data, _) = try await URLSession.shared.data(
            from: URL(string: "http://numbersapi.com/\(number)")!
        )
        return String(data: data, encoding: .utf8) ?? ""
    }
    static let testValue = NumberFactClient { _ in "test fact" }
}

extension DependencyValues {
    var numberFact: NumberFactClient {
        get { self[NumberFactClient.self] }
        set { self[NumberFactClient.self] = newValue }
    }
}

Testing with TestStore

@MainActor
func testFetchFact() async {
    let store = TestStore(initialState: CounterFeature.State(count: 5)) {
        CounterFeature()
    } withDependencies: {
        $0.numberFact = NumberFactClient { n in "\(n) is great" }
    }

    await store.send(.getFactButtonTapped) {
        $0.isLoadingFact = true
    }
    await store.receive(\.factResponse) {
        $0.isLoadingFact = false
        $0.fact = "5 is great"
    }
}

TestStore fails the test if any action or state change goes unasserted. This exhaustiveness catches bugs your unit tests would never see.

What TCA costs

  • Steeper learning curve: junior engineers need 1–2 weeks of focused work to be productive.
  • Compile times: macro expansion adds noticeable build time on large feature graphs (work around with smaller modules).
  • Library lock-in: Point-Free moves fast; you’ll be tracking their breaking changes.
  • Verbose for simple screens: a single-button screen has 40+ lines of TCA before any logic.

In the wild

  • Isowords (Point-Free’s own multiplayer Scrabble app) — full TCA reference codebase, open source.
  • Wikipedia iOS — gradually adopting TCA for new features.
  • NY Times Cooking — TCA in some flows.
  • Many teams adopt TCA at a feature level (one screen flow), not whole-app, to limit risk.

Common misconceptions

  1. “TCA is just Redux for Swift.” Closer to Elm; it bakes in side effects and dependency injection in a way Redux doesn’t.
  2. “You don’t need MVVM if you have TCA.” TCA replaces MVVM for that feature; mixing both adds confusion.
  3. “TCA is too heavy for any app.” It’s heavy per screen but scales beautifully — feature composition (Scope, forEach) shines for complex flows.
  4. “You can’t use UIKit with TCA.” You can — TCA has UIViewController bindings via observe { }, though SwiftUI is the primary path.
  5. “Effects must be async.” They can be sync (.run with no await), but you rarely need that.

Seasoned engineer’s take

TCA is a team decision more than a technical one. Adopt it when (a) the team is committed to the Point-Free ecosystem, (b) you have at least one engineer who can mentor others, and (c) you’re building something complex enough to amortize the boilerplate (real-time apps, multi-step flows, undo/redo). Don’t adopt it just because you saw it on the conference circuit.

TIP: Try TCA on one isolated feature first — a settings screen or onboarding flow. Measure team velocity before and after. Decide based on data.

WARNING: TCA + SwiftData has rough edges; TCA + Core Data is well-trodden. Match your data layer choice to TCA maturity for that layer.

Interview corner

Junior: “What is TCA?” A Swift library that brings unidirectional data flow (Redux-style) to SwiftUI/UIKit apps: every event is an Action, state mutations go through a pure reducer, side effects return more Actions, and there’s a built-in TestStore for exhaustive testing.

Mid: “How does TCA’s Effect differ from Combine.Publisher?” Effect is async-first and tightly integrated with the reducer’s Action enum — every effect ultimately produces zero or more Actions the reducer can handle. Publisher is general-purpose reactive plumbing without the action/state framing.

Senior: “When would you advise against TCA?” For small apps where the boilerplate exceeds the testability win; for teams without a TCA champion to mentor adopters; and where compile-time constraints matter — TCA’s macros and exhaustive switch coverage add measurable build time that’s worth measuring in CI before commitment. I’d also avoid mixing TCA features with MVVM features in the same module: pick one paradigm per module to avoid cognitive thrash.

Red-flag answer: “TCA is the best architecture, period.” Architectures are tradeoffs; this answer reveals lack of production experience with multiple paradigms.

Lab preview

No dedicated TCA lab — the Point-Free tutorials (free, excellent, ~6 hours) are the canonical way in. Once you’ve completed those, retrofit one screen of your portfolio app to TCA and live with it for two weeks; you’ll know whether it’s right for your style.


Next: 12.6 — Modularization with SPM