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
| Concept | Role |
|---|---|
State | Immutable value type capturing everything the feature displays |
Action | Enum cataloguing every event (user tap, network response, timer fire) |
Reducer | Pure function (inout State, Action) -> Effect<Action> |
Effect | Anything async (network, timer, dependency call); returns more Actions |
Store | Owns state, runs reducer, dispatches effects |
TestStore | Lets you assert every state transition + effect during tests |
Dependencies | Compile-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
- “TCA is just Redux for Swift.” Closer to Elm; it bakes in side effects and dependency injection in a way Redux doesn’t.
- “You don’t need MVVM if you have TCA.” TCA replaces MVVM for that feature; mixing both adds confusion.
- “TCA is too heavy for any app.” It’s heavy per screen but scales beautifully — feature composition (
Scope,forEach) shines for complex flows. - “You can’t use UIKit with TCA.” You can — TCA has
UIViewControllerbindings viaobserve { }, though SwiftUI is the primary path. - “Effects must be async.” They can be sync (
.runwith noawait), 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.