All posts
Swift TCA Architecture SwiftUI iOS

The Composable Architecture: A Deep Dive

TCA (The Composable Architecture) by Point-Free has become one of the most discussed architectural patterns in the iOS world. It polarizes teams: some swear by it, others find it overwhelming. This article is a structured deep dive — not an intro, not a sales pitch. If you’ve seen TCA before but want to understand why each piece exists and how to use it in anger, this is for you.

The Four Primitives

Every TCA feature is built from four things:

  • State — a value type (struct) that represents everything the feature needs to render and operate
  • Action — an enum that describes every possible event: user taps, network responses, timer ticks
  • Reducer — a pure function (inout State, Action) -> Effect<Action> that mutates state and optionally returns side effects
  • Store — the runtime that holds state, processes actions through the reducer, and drives the view
@Reducer
struct CounterFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
        var isLoading = false
    }

    enum Action {
        case incrementTapped
        case decrementTapped
        case resetTapped
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementTapped:
                state.count += 1
                return .none
            case .decrementTapped:
                state.count -= 1
                return .none
            case .resetTapped:
                state.count = 0
                return .none
            }
        }
    }
}

@ObservableState makes State observable by SwiftUI without any extra plumbing. The view observes only what it accesses — no unnecessary re-renders.

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

    var body: some View {
        VStack(spacing: 16) {
            Text("\(store.count)").font(.largeTitle)
            HStack {
                Button("-") { store.send(.decrementTapped) }
                Button("+") { store.send(.incrementTapped) }
            }
            Button("Reset") { store.send(.resetTapped) }
        }
    }
}

The store.send(_:) API is the only way to mutate state — there’s no way to accidentally set a property from outside the reducer.


Effects: Handling the Outside World

Pure reducers can’t call APIs, read the clock, or access the file system. That work happens in Effect — a structured wrapper around async operations.

@Reducer
struct SearchFeature {
    @ObservableState
    struct State: Equatable {
        var query = ""
        var results: [Item] = []
        var isLoading = false
    }

    enum Action {
        case queryChanged(String)
        case searchResponse(Result<[Item], Error>)
    }

    @Dependency(\.apiClient) var apiClient
    @Dependency(\.continuousClock) var clock

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .queryChanged(query):
                state.query = query
                state.isLoading = true

                // Cancel any in-flight request, debounce by 0.3s
                return .run { [query] send in
                    try await clock.sleep(for: .milliseconds(300))
                    let results = try await apiClient.search(query)
                    await send(.searchResponse(.success(results)))
                } catch: { error, send in
                    await send(.searchResponse(.failure(error)))
                }
                .cancellable(id: "search", cancelInFlight: true)

            case let .searchResponse(.success(results)):
                state.isLoading = false
                state.results = results
                return .none

            case .searchResponse(.failure):
                state.isLoading = false
                state.results = []
                return .none
            }
        }
    }
}

Key points here:

  • .cancellable(id:cancelInFlight:) automatically cancels the previous effect when a new action arrives — debounce built-in
  • clock.sleep is injectable, so tests can control time without Task.sleep
  • The error path is explicit — you can’t silently swallow failures

Dependencies: Replacing the Live World in Tests

TCA’s dependency system is one of its biggest selling points. It’s built on Swift’s @TaskLocal under the hood and lets you define, register, and inject dependencies with minimal noise.

Defining a dependency

struct APIClient {
    var search: @Sendable (String) async throws -> [Item]
    var fetchDetail: @Sendable (String) async throws -> Item
}

extension APIClient: DependencyKey {
    static let liveValue = APIClient(
        search: { query in
            // real URLSession call
        },
        fetchDetail: { id in
            // real URLSession call
        }
    )

    // Used automatically in previews
    static let previewValue = APIClient(
        search: { _ in Item.mocks },
        fetchDetail: { _ in .mock }
    )

    // Used automatically in tests
    static let testValue = APIClient(
        search: { _ in [] },
        fetchDetail: { _ in .mock }
    )
}

extension DependencyValues {
    var apiClient: APIClient {
        get { self[APIClient.self] }
        set { self[APIClient.self] = newValue }
    }
}

Consuming it in a reducer

@Dependency(\.apiClient) var apiClient

That’s it. In production, TCA uses liveValue. In tests, testValue. In previews, previewValue. You can override any dependency per-scope:

SearchView(
    store: Store(initialState: SearchFeature.State()) {
        SearchFeature()
    } withDependencies: {
        $0.apiClient.search = { _ in Item.mocks }
    }
)

Feature Composition

The real power of TCA shows when you need to build a complex app from smaller features. The @Reducer macro makes this almost mechanical.

Scoping state

@Reducer
struct AppFeature {
    @ObservableState
    struct State: Equatable {
        var search = SearchFeature.State()
        var profile = ProfileFeature.State()
        var selectedTab: Tab = .search

        enum Tab { case search, profile }
    }

    enum Action {
        case search(SearchFeature.Action)
        case profile(ProfileFeature.Action)
        case tabSelected(State.Tab)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.search, action: \.search) {
            SearchFeature()
        }
        Scope(state: \.profile, action: \.profile) {
            ProfileFeature()
        }
        Reduce { state, action in
            switch action {
            case let .tabSelected(tab):
                state.selectedTab = tab
                return .none
            case .search, .profile:
                return .none
            }
        }
    }
}

Scope runs the child reducer on its slice of state. The parent knows nothing about how the child works — it just routes actions and composes state.

The view side

struct AppView: View {
    let store: StoreOf<AppFeature>

    var body: some View {
        TabView(selection: $store.selectedTab.sending(\.tabSelected)) {
            SearchView(store: store.scope(state: \.search, action: \.search))
                .tabItem { Label("Search", systemImage: "magnifyingglass") }
                .tag(AppFeature.State.Tab.search)

            ProfileView(store: store.scope(state: \.profile, action: \.profile))
                .tabItem { Label("Profile", systemImage: "person") }
                .tag(AppFeature.State.Tab.profile)
        }
    }
}

store.scope(state:action:) creates a derived store for the child view. The child view only sees its own state — it doesn’t know about the parent.


This is where TCA diverges most dramatically from standard SwiftUI patterns. Instead of binding navigation to @State var isPresentingDetail = false, you model it explicitly in the feature’s state.

Optional presentation (sheet/popover)

@Reducer
struct ItemListFeature {
    @ObservableState
    struct State: Equatable {
        var items: [Item] = []
        @Presents var detail: ItemDetailFeature.State?
    }

    enum Action {
        case itemTapped(Item)
        case detail(PresentationAction<ItemDetailFeature.Action>)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .itemTapped(item):
                state.detail = ItemDetailFeature.State(item: item)
                return .none
            case .detail:
                return .none
            }
        }
        .ifLet(\.$detail, action: \.detail) {
            ItemDetailFeature()
        }
    }
}

struct ItemListView: View {
    @Bindable var store: StoreOf<ItemListFeature>

    var body: some View {
        List(store.items) { item in
            Button(item.title) { store.send(.itemTapped(item)) }
        }
        .sheet(item: $store.scope(state: \.detail, action: \.detail)) { detailStore in
            ItemDetailView(store: detailStore)
        }
    }
}

@Presents is a property wrapper that models optional presentation. When it’s non-nil, the sheet appears. When it’s set to nil (either by the user dismissing or via an action), the reducer can react.

Stack navigation

@Reducer
struct AppNavigationFeature {
    @ObservableState
    struct State: Equatable {
        var path = StackState<Path.State>()
    }

    @Reducer(state: .equatable)
    enum Path {
        case detail(DetailFeature)
        case settings(SettingsFeature)
    }

    enum Action {
        case path(StackActionOf<Path>)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}

struct AppNavigationView: View {
    @Bindable var store: StoreOf<AppNavigationFeature>

    var body: some View {
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            RootView(store: store)
        } destination: { store in
            switch store.case {
            case let .detail(store): DetailView(store: store)
            case let .settings(store): SettingsView(store: store)
            }
        }
    }
}

The entire navigation stack is part of the app’s state. Deep links become trivial: set state.path to the desired stack and the UI catches up automatically.


Testing

TCA testing is exhaustive by design. TestStore requires you to account for every state mutation — there are no hidden changes.

@Test
func searchDebounceAndResults() async {
    let clock = TestClock()
    let store = TestStore(initialState: SearchFeature.State()) {
        SearchFeature()
    } withDependencies: {
        $0.continuousClock = clock
        $0.apiClient.search = { query in
            [Item(id: "1", title: "Result for \(query)")]
        }
    }

    // User types
    await store.send(.queryChanged("swift")) {
        $0.query = "swift"
        $0.isLoading = true
    }

    // Advance clock past debounce threshold
    await clock.advance(by: .milliseconds(300))

    // API response arrives
    await store.receive(\.searchResponse.success) {
        $0.isLoading = false
        $0.results = [Item(id: "1", title: "Result for swift")]
    }
}

If you forget to assert a state change, the test fails. If an unexpected action arrives, the test fails. This forces you to think about every transition explicitly — which is annoying during prototyping but invaluable when debugging regressions.

Testing cancellation

@Test
func inflight_requestIsCancelledOnNewQuery() async {
    let clock = TestClock()
    let store = TestStore(initialState: SearchFeature.State()) {
        SearchFeature()
    } withDependencies: {
        $0.continuousClock = clock
        $0.apiClient.search = { _ in [] }
    }

    await store.send(.queryChanged("s")) {
        $0.query = "s"
        $0.isLoading = true
    }

    // New query before debounce fires — cancels the previous effect
    await store.send(.queryChanged("sw")) {
        $0.query = "sw"
    }

    await clock.advance(by: .milliseconds(300))
    await store.receive(\.searchResponse.success) {
        $0.isLoading = false
    }
}

When TCA Pays Off

After using TCA in production across multiple apps, these are the scenarios where it genuinely earns its overhead:

Apps with complex async flows. Cancellation, debouncing, chaining effects — TCA’s Effect system handles this far better than scattered Task management in ViewModels.

Multi-module codebases. Each module exports a Feature struct. Integration is mechanical: Scope in the parent, store.scope in the view. No shared singletons, no coordinator chains.

Teams that write tests. If you’re not going to test business logic, TCA’s testability advantage disappears. But if you are, TestStore removes an entire class of async testing pain.

Apps with non-trivial navigation. Stack-based navigation with deep links, state restoration, or navigation-driven push notifications becomes manageable when navigation is state.

When It Gets in the Way

Solo projects or MVPs. The upfront structure is real friction when requirements are changing daily.

Simple CRUD screens. A list → detail flow with no side effects doesn’t need reducers, effects, and scoping. MVVM is faster and just as maintainable.

Teams new to functional patterns. Effect, Scope, @Presents, StackState — the vocabulary is large. Budget time for onboarding or you’ll end up with TCA that nobody on the team feels confident modifying.


TCA is not a silver bullet, but it’s a serious tool for serious problems. The patterns above — cancellable effects, composable state, navigation as data, exhaustive tests — are genuinely hard to replicate with equivalent rigor in other architectures. The question is always whether your problem is complex enough to justify the investment.

For reference, the official TCA documentation and the Point-Free video series are the best learning resources available. The documentation has improved significantly in recent versions.