What are the trade-offs of using The Composable Architecture (TCA)?
What TCA is
Unidirectional data flow libraries like TCA (Point-Free’s Composable Architecture) bring a Redux/Elm-style discipline to Swift: state lives in one place per feature, all mutations happen through explicit Actions processed by a Reducer, and side effects are modeled as first-class Effect values rather than ad-hoc async calls scattered through view models.
What you actually get
- Deterministic, exhaustive testing.
TestStoreforces you to assert every state mutation and every effect a reducer produces, and pairs withTestClock/controlled dependencies so time- and network-dependent logic becomes reproducible in unit tests without mocking frameworks. This is consistently cited as TCA’s strongest selling point — testing quality most teams can’t easily replicate by hand. - Built-in dependency injection.
@Dependencygives you live/test/preview implementations per dependency, so SwiftUI previews and tests get sensible defaults without a separate DI container. - Composition primitives that scale.
scope,ifLet,forEach, andReducelet you build large features out of small, independently testable reducers, and the navigation tools (tree-based and stack-based) unify sheets, alerts, confirmation dialogs, and push navigation under one state-driven model — which eliminates a whole class of imperative navigation bugs (state and UI getting out of sync) and makes deep linking and state restoration much more tractable. - Team-wide consistency. Because every feature follows the same State/Action/Reducer shape, code review and onboarding into an unfamiliar module is more predictable than N different bespoke MVVM implementations.
- Less boilerplate than it used to be. The move to the Observation framework (
@ObservableState,@Reducer,@Presents,@Bindable) cut a lot of the oldWithViewStore/ViewStoreceremony and improved view-update granularity to match vanilla@Observablebehavior — views only re-render for the state they actually read.
What it costs you
- Learning curve and indirection. Every tap becomes an
Actionsent throughstore.send()and traced through a reducer body rather than a direct method call. For developers used to plain SwiftUI/MVVM, this is a genuine mental-model shift, not just new syntax — Point-Free’s own FAQ recommends against TCA for beginners and for simple “load JSON and display it” apps. - Boilerplate is reduced, not gone. You still declare
State,Action, and a reducer body for screens that in plain@ObservableMVVM would be a handful of@Published-equivalent properties and methods. For CRUD-simple screens the ratio of ceremony to logic is often worse, not better. - Weak encapsulation by design. Any parent reducer can reach into any child’s state or actions — there’s no real information hiding between features. That’s what makes composition easy, but engineers who’ve run large TCA codebases report it lets teams build undocumented cross-feature dependencies that break silently when a child’s internals change.
- Massive reducers. Left undisciplined, app-level reducers become the TCA equivalent of Massive View Controller — one team reported their root reducer got large enough that Xcode struggled to scroll it or produce reliable compiler errors.
- Compile-time cost. Heavy macro use (
@Reducer,@ObservableState,@Presents) plus deeply nested enum-based reducer composition can meaningfully slow builds; changing code in one feature frequently forces recompilation of much more than expected. This is a recurring complaint in the project’s own GitHub discussions, not just anecdote. - Effect and cancellation bugs are a real category. Long-lived effects need careful ID-based cancellation, and self-sending/recursive actions have caused documented stack and performance issues. Getting this wrong is subtle and easy to miss in review.
- API churn and version lock-in. The library has shipped extremely fast — around 95 releases across three years by one long-time user’s count — with real breaking changes along the way (
ViewStoredeprecation,@BindingStatereplaced,Effect/EffectOfrenames, the whole Observation migration). Swift Package Manager can’t resolve two versions of the same package simultaneously, so in a multi-team app everyone has to upgrade in lockstep, which is a real coordination tax at scale. - Single-vendor dependency. TCA is maintained essentially by two people at Point-Free. There’s no Apple backing, and as Apple’s own
@Observable/@Environment/structured concurrency have matured, part of what TCA originally had to build for you (fine-grained observation, environment-style DI) is now available natively — narrowing TCA’s marginal advantage for small-to-medium apps, though its testing story and navigation unification remain harder to replicate by hand. - Platform floor. The ergonomic, Observation-backed form of TCA wants iOS 17+; earlier OS support means either the Perception back-port (with its own quirks) or the older, clunkier
ViewStore-based API.
Where the calculus shifts
The strongest case for TCA (or a similar library) is a large, long-lived app maintained by a rotating team, with genuinely complex cross-screen state, deep-linking/navigation requirements, and a need for rigorous, deterministic testing (regulated industries, financial or health apps, anything where “it worked when I tapped through it manually” isn’t good enough).
The weakest case is a small app, a solo developer or small team already comfortable with a lighter pattern, prototypes, or performance-sensitive/animation-heavy screens where the extra indirection has a real cost.
In between, a lot of teams now reach for a lighter home-grown unidirectional pattern (a hand-rolled @Observable store with an enum of actions, no macro, no third-party dependency) to get most of the predictability without the compile-time and versioning costs — worth considering as a middle ground before committing a whole codebase to TCA.
What is a reducer?
A reducer is the function that owns the logic for turning a (state, action) pair into new state — it’s the piece that answers “given what happened, how should the state change?” It comes from the same lineage as Redux and Elm: (State, Action) -> State, conceptually pure and deterministic (no hidden side effects baked into the mutation itself).
In TCA specifically, a reducer is a type conforming to the Reducer protocol with associated State and Action types, and a body (or reduce(into:action:) method) that Swift’s @Reducer macro wires up for you. Because Swift is value-type-friendly, TCA reducers mutate state in place via inout rather than returning a brand-new copy, which is cheaper than the classic Redux “always return new state” style:
@Reducer
struct CounterFeature {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case incrementTapped
case decrementTapped
}
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
}
}
}
}
A few things worth knowing about how reducers behave in this model:
- The switch over
Actionis exhaustive — every case your app can trigger has to be handled somewhere, which is part of why testing is so thorough: nothing can mutate state without going through this switch. - The return value isn’t the new state (that was already mutated via
inout); it’s anEffect<Action>describing any async work to kick off — a network call, a timer, a debounce — or.noneif the action was purely synchronous. When that effect eventually produces a result, it comes back in as anotherAction, which flows through the same reducer again. - Reducers compose. A parent feature’s reducer doesn’t reinvent a child feature’s logic — it embeds the child reducer (via
Scope,ifLet,forEach, etc.) and lets it handle its own slice of state and its own actions, then optionally reacts to child actions bubbling up. That’s the “composable” in Composable Architecture: big features are trees of small reducers rather than one giant switch statement (though, as noted above, without discipline that tree can still collapse back into one giant switch statement at the root).
Sources
- Releases · pointfreeco/swift-composable-architecture
- The Composable Architecture: My 3 Year Experience • Rod Schmidt
- Composable Architecture Frequently Asked Questions
- Performance - Send Recursion · pointfreeco/swift-composable-architecture · Discussion #1290
- Build times much longer: ComposableArchitectureMacros · Discussion #2776
- TCA’s Macros and CI/CD - Swift Forums