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:
stateisinout, so you mutate it directly, no copies, noreturn newState. It feels imperative, but since the reducer is pure it stays predictable.- Every branch returns an effect.
.nonemeans there’s nothing else to do. In a moment we return something more interesting. - The
switchis 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. ImmediateClockremoves the wait: the 300 ms debounce passes instantly and the test runs in microseconds.- The
sendclosure 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. receiverequires the action to arrive. If the effect never sends.resultsReceived, the test fails.TestStoreis 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
Taskbookkeeping. - 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
@Observablewith 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