SwiftTCAArchitectureSwiftUIiOS

TCA from scratch (part 1): building a search feature with the 2026 API

The classic example for explaining TCA is a counter. It’s useful for seeing the pieces, but a counter has no networking, no cancellation, no dependencies to mock and no shared state — exactly the problems that would make you pick TCA.

In this series we put together a search feature with debounce and cancellation, something that exists in practically every production app, and the TCA concepts come out of the work as we need them.

One warning before we start: TCA has changed a lot. The current version is 1.26 (June 2026), and 1.25 deprecated a good chunk of the API that’s still circulating from a couple of years ago: ViewStore, WithViewStore, BindingViewStore, Store.withState. Everything here uses the current API.

The three pieces

TCA rests on three ideas. Worth naming them before writing code, even though you’ll understand them better by using them:

  • The state is everything your feature needs to know, in one place.
  • The actions are everything that can happen: user taps, network responses, timers. It helps to think of them as things that already occurred.
  • The reducer is the function that, given a state and an action, decides the new state and which effects to fire.

The golden rule is that the reducer stays a pure function. It doesn’t do networking, doesn’t touch disk, doesn’t read the clock. When it needs something from the real world, it returns an effect describing it. Coming from MVVM, that separation was the hardest part for me to internalize, and it’s the one that later makes everything testable.

Step 1: state and actions

Let’s start minimal. Our search feature needs to know what the user typed and what results it has:

import ComposableArchitecture

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

    enum Action {
        case queryChanged(String)
        case resultsReceived([Repository])
    }
}

Two macros do the heavy lifting. @Reducer generates the scaffolding (protocol conformance, associated types, and later what we need for composition). @ObservableState wires the state into Swift’s Observation system, which is what lets the view read store.query directly, with no ViewStore or WithViewStore. If you run into code using WithViewStore { viewStore in ... }, it comes from an earlier version.

Notice the action names: queryChanged, resultsReceived. They’re past tense because they describe facts. It looks like a cosmetic convention, but it changes how you think about the feature: the reducer reacts to what already happened.

Step 2: the reducer

Now the logic. The reducer’s body goes in the body property:

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

    enum Action {
        case queryChanged(String)
        case resultsReceived([Repository])
    }

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

            case let .resultsReceived(repos):
                state.results = repos
                return .none
            }
        }
    }
}

Three things to notice:

  1. state is inout, so you mutate it directly, no copies, no return newState. It feels imperative, but since the reducer is pure it stays predictable.
  2. Every branch returns an effect. .none means there’s nothing else to do. In a moment we return something more interesting.
  3. The switch is exhaustive. Add an action and forget to handle it, and the compiler stops you. In a ViewModel with loose methods the action you forgot to hook up does exist; here it can’t.

Step 3: wiring the view

The view receives a Store and reads state directly:

import SwiftUI
import ComposableArchitecture

struct SearchView: View {
    @Bindable var store: StoreOf<SearchFeature>

    var body: some View {
        List(store.results) { repo in
            VStack(alignment: .leading) {
                Text(repo.name).font(.headline)
                Text(repo.description).font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .searchable(text: $store.query.sending(\.queryChanged))
    }
}

Two important things happen here. @Bindable var store lets you derive bindings with $store, without going through ViewStore. And .sending(\.queryChanged) turns that binding into action dispatch: when the user types, .queryChanged(text) goes to the store.

The view never mutates state. It only describes what it sees and reports what happened.

A shortcut for forms

If your feature has many fields (a settings form, say), writing one action per field gets tedious. That’s what BindingReducer is for:

enum Action: BindableAction {
    case binding(BindingAction<State>)
}

var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
        // your logic
    }
}

And in the view, $store.query works directly, no .sending(...). This is still the recommended approach in the current version.

Step 4: the first effect

So far we haven’t touched the network. The reducer can’t make async calls, but it can return an effect that describes them:

case let .queryChanged(text):
    state.query = text
    return .run { send in
        let repos = try await searchRepos(text)
        await send(.resultsReceived(repos))
    }

.run creates an async effect. Inside you can await normally, and when you have the result you send it back to the reducer with send. The loop closes: action → effect → action.

But this code has two bugs you feel in production.

The first is that it fires on every keystroke: if the user types “swift”, you launch five searches.

The second is a race condition. If the search for “swi” takes longer than the one for “swift”, the stale results arrive last and overwrite the fresh ones, and the user ends up seeing results that don’t match what they typed.

Step 5: debounce and cancellation

TCA solves both with cancellation identifiers.

private enum CancelID { case search }

// ...

case let .queryChanged(text):
    state.query = text

    guard !text.isEmpty else {
        state.results = []
        return .cancel(id: CancelID.search)
    }

    return .run { send in
        try await Task.sleep(for: .milliseconds(300))   // debounce
        let repos = try await searchRepos(text)
        await send(.resultsReceived(repos))
    }
    .cancellable(id: CancelID.search, cancelInFlight: true)

The key is cancelInFlight: true. Every time a new keystroke arrives, the previous effect with the same ID is cancelled automatically, so only the last search survives. And the initial Task.sleep ends up working as a debounce, because it gets cancelled before completing if the user keeps typing.

By hand, in a ViewModel, this means storing the Task in a property, cancelling it on every change, and remembering to clean it up when the view disappears. Here it’s one line.

Step 6: the dependency

One serious problem remains: searchRepos comes out of nowhere. If it’s a global function that hits the network, you can’t test the feature without internet.

TCA ships its own dependency injection system. First you declare the client as a struct of closures:

struct ReposClient {
    var search: (String) async throws -> [Repository]
}

extension ReposClient: DependencyKey {
    // The real implementation, used by the app
    static let liveValue = ReposClient(
        search: { query in
            let url = URL(string: "https://api.example.com/search?q=\(query)")!
            let (data, _) = try await URLSession.shared.data(from: url)
            return try JSONDecoder().decode([Repository].self, from: data)
        }
    )

    // The test implementation: predictable, no network
    static let testValue = ReposClient(
        search: { _ in [] }
    )
}

extension DependencyValues {
    var reposClient: ReposClient {
        get { self[ReposClient.self] }
        set { self[ReposClient.self] = newValue }
    }
}

Why a struct of closures instead of a protocol? Because it lets you swap a single function in a test without implementing the whole interface. It’s a deliberate decision in the library, and you appreciate it when your client has fifteen methods and the test only needs to change one.

Now use it in the reducer:

@Reducer
struct SearchFeature {
    @Dependency(\.reposClient) var reposClient
    @Dependency(\.continuousClock) var clock

    // ...

    case let .queryChanged(text):
        state.query = text
        guard !text.isEmpty else {
            state.results = []
            return .cancel(id: CancelID.search)
        }
        return .run { [reposClient, clock] send in
            try await clock.sleep(for: .milliseconds(300))
            let repos = try await reposClient.search(text)
            await send(.resultsReceived(repos))
        }
        .cancellable(id: CancelID.search, cancelInFlight: true)
}

Two dependencies showed up. The second one is the clock: I swapped the previous step’s Task.sleep for clock.sleep, and the reason is purely about testing. With Task.sleep, your test would have to wait a real 300 ms. With an injected clock you can substitute one that waits for nothing. Multiply that by a hundred tests and the difference between a one-second suite and a half-minute one lives in that single line.

The reducer no longer knows how searching works, or how waiting works. It only knows something searches, and that’s why it can be tested.

Step 7: handling the error

A detail that’s easy to overlook: try await can fail, and if it fails inside .run, the error is swallowed silently. In production that’s a blank screen with no explanation.

Add an action for the failure:

enum Action {
    case queryChanged(String)
    case resultsReceived([Repository])
    case searchFailed(String)
}

And catch the error in the effect:

return .run { [reposClient, clock] send in
    try await clock.sleep(for: .milliseconds(300))
    let repos = try await reposClient.search(text)
    await send(.resultsReceived(repos))
} catch: { error, send in
    await send(.searchFailed(error.localizedDescription))
}
.cancellable(id: CancelID.search, cancelInFlight: true)

Watch out for one detail: cancellation also throws (CancellationError). If you don’t want to show a message every time the user types another letter, filter that case out before reporting.

Step 8: the test

This is where TCA pays back the ceremony you invested. TestStore verifies every state change, step by step:

import ComposableArchitecture
import Testing

@Test
func searchReturnsResults() async {
    let store = await TestStore(initialState: SearchFeature.State()) {
        SearchFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.reposClient.search = { _ in
            [Repository(id: 1, name: "swift", description: "The language")]
        }
    }

    await store.send(.queryChanged("swift")) {
        $0.query = "swift"
    }

    await store.receive(\.resultsReceived) {
        $0.results = [Repository(id: 1, name: "swift", description: "The language")]
    }
}

A few things worth pointing out about this test:

  • You substitute only the closure you care about (search), leaving the rest of the client alone.
  • ImmediateClock removes the wait: the 300 ms debounce passes instantly and the test runs in microseconds.
  • The send closure describes the expected state. If the reducer changes something you didn’t declare, the test fails; if you declare a change that doesn’t happen, it fails too.
  • receive requires the action to arrive. If the effect never sends .resultsReceived, the test fails.
  • TestStore is exhaustive by default, so an effect your feature fires and you don’t verify breaks the test as well.

That last point is the most underrated. In a typical ViewModel, an unverified effect simply doesn’t show up in coverage and nobody notices.

What we gained (and what it cost)

Let’s review what we built: a search feature with debounce, in-flight request cancellation, error handling, an injectable dependency and a test that verifies the whole flow without touching the network.

What TCA gave us:

  • Declarative cancellation in one line, no manual Task bookkeeping.
  • The exhaustive switch, which makes forgetting an action impossible.
  • Tests that verify state and also effects.
  • A swappable dependency without writing a whole protocol.

What it cost:

  • More ceremony than an @Observable with two properties. For a trivial screen it’s overkill.
  • A real learning curve: macros, effects, dependencies and a different mental model.
  • Longer compile times on large projects.

TCA isn’t free. It pays off when your feature has complex state and concurrent effects, and when you’re actually going to write tests. For an “about” screen, it doesn’t.

What’s next

We have a complete, isolated feature. But real apps aren’t one screen: they’re dozens of features that navigate between each other, share state and group into modules.

In part 2 we compose: how one feature contains another, how navigation works with state, and why the @Reducer enum for destinations changed the way navigation is modeled in TCA.

After that come shared state across features, testing in depth, and an honest comparison against MVVM and the other architectures. And at the end of the series we’ll rebuild the Pokédex from the previous series entirely in TCA, to compare both approaches on the same real problem.

Sources: swift-composable-architecture (GitHub) · 1.25 migration guide · Bindings documentation