The iOS developer community got very loud about The Composable Architecture around 2020. Point-Free, the video series by Brandon Williams and Stephen Celis, released it as an open source library and proceeded to put out dozens of hours of content explaining the philosophy behind it. A lot of developers watched, felt the intellectual appeal, and adopted it. Some of them love it. Some of them quietly removed it six months later.
I've been watching this debate long enough that I want to give it an honest read, not a defensive one. TCA solves real problems. It also introduces real costs. Whether those costs are worth it depends almost entirely on what you're building.
What TCA actually is
TCA is a Swift library built around a single architecture idea: your entire app's state lives in one place, mutates through a pure function, and every side effect is handled explicitly.
The mental model comes from Redux and before that The Elm Architecture. You have State, which is a struct holding all the data your feature needs. You have Action, an enum listing every possible event. You have a Reducer, a function that takes the current state and an action and returns the next state, plus any Effects that need to run. The Store runs all of this and feeds state updates back to your views.
@Reducer
struct CounterFeature {
struct State: Equatable {
var count = 0
}
enum Action {
case incrementTapped
case decrementTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
}
}
}
}
The view wires up to a Store and dispatches actions instead of mutating state directly.
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
WithPerceptionTracking {
HStack {
Button("-") { store.send(.decrementTapped) }
Text("\(store.count)")
Button("+") { store.send(.incrementTapped) }
}
}
}
}
Everything flows one direction. Actions go in, state comes out, the view re-renders. There is no way to mutate state from the view directly, no hidden side channels, no delegate callbacks flying sideways through the codebase. That strictness is the whole point.

What it actually solves
The problems TCA targets are real. If you have worked on a large iOS codebase where the state was spread across ViewModels, singletons, UserDefaults, notification observers, and delegate callbacks, you already know what "spooky action at a distance" feels like. Something happens on screen B, and screen A breaks, and you spend two hours with a stack trace trying to figure out who touched what.
TCA makes that class of bug structurally impossible. State only changes when an action arrives at the reducer. You can trace any state change back to a specific action in a log, like reading history. When something breaks, you know exactly where to look.
The TestStore is where TCA earns most of its reputation.
func testIncrement() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementTapped) {
$0.count = 1
}
}
By default, TestStore is exhaustive. If your reducer changed five things and you only asserted on four, the test fails. You cannot accidentally miss a state change. Compare that to testing a ViewModel with @Published properties, where you have to manually subscribe, observe, and assert against a sequence of emitted values. The TCA version is just... cleaner.
Side effects are also testable without mocking an entire URLSession. You wrap dependencies behind a @Dependency key, and in tests you override them with anything you want:
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.apiClient.fetchUser = { .mock }
$0.continuousClock = ImmediateClock()
}
You can also control time. An ImmediateClock collapses delays to zero. That means a timer-based feature that would take minutes to test manually runs in milliseconds. For that specific problem, TCA's solution is elegant.
Composition is the other area where TCA earns its overhead. Large apps break into features and features into sub-features. TCA's Scope operator slices a child reducer out of a parent with clean boundaries, so two teams can work on two features without stepping on each other. The parent reducer knows nothing about the sub-feature's internal state mutations. They stay isolated and communicate through explicit actions.
The real costs
Now for the part the tutorials tend to understate.
The boilerplate is significant. A counter with one button looks fine in the tutorial. A real screen with a search bar, filtered list, pagination, empty states, error banners, and a detail navigation push looks like this:
enum Action {
case searchTextChanged(String)
case searchResponse(Result<[Item], Error>)
case itemTapped(Item)
case loadMoreTapped
case loadMoreResponse(Result<[Item], Error>)
case errorDismissed
case detail(PresentationAction<DetailFeature.Action>)
}
Every single thing the view can do appears in this enum. Every response from every network call. Every navigation event. Every dismissal. For a feature that responds to many inputs, this enum grows, and all of it has to be handled in the reducer switch statement. It is a lot to read, a lot to maintain, and a lot to explain to a new developer on the team.
The macros (@Reducer, @ObservableState, @DependencyClient) introduced in 2023 and 2024 eliminated some of this. The 2024 version of TCA is much less painful to write than the 2021 version. But the core structural overhead is still there.
Compile times are another complaint that comes up constantly, and not without reason. Heavy use of macros, complex generics, and deeply nested Scope compositions can slow incremental compilation in ways that are hard to pin down. On a large codebase, that adds up across a workday.
TCA also fights some of SwiftUI's defaults. SwiftUI was built around small, composable views with local @State. The framework has strong opinions about navigation, animations, and view identity. TCA has its own opinions, and they don't always line up. Navigation is one of the more painful areas. The library routes through PresentationState and PresentationAction, and wiring it correctly takes real study. Apple changes how SwiftUI navigation works almost every year, and TCA has to track those changes on its own schedule.
Once a codebase adopts TCA end to end, it is structurally difficult to leave. Every screen is wired into a Store. Every interaction is an Action. Removing the framework means rewriting the architecture from scratch. Teams that adopted it for a small app and later realized they didn't need it can't easily roll back. The cost is asymmetric: cheap to add, expensive to remove.
The library has also gone through multiple significant API shifts since 2020. The concurrency model changed in 2022. Macros arrived in 2023 and changed how reducers are written. The 2024 @ObservableState migration was another round of updates. Each time, Point-Free ships migration guides and the changes are genuine improvements. But absorbing them on a large codebase is real engineering work that blocks feature development for stretches of time.
Why people go crazy about it

Understanding the enthusiasm requires looking at the alternatives in large codebases.
Take a team of fifteen developers, two years into a mature app. They started with MVVM. Each developer has their own interpretation of what goes in a ViewModel, what goes in a service, what triggers navigation, and how to handle errors. There is no consistent answer, so each screen is slightly different. State is scattered. ViewModels hold references to other ViewModels. Side effects happen in a dozen places. Testing requires complex setup and produces fragile tests that break on unrelated changes. Debugging a bug in screen D means reading the code for screens A, B, C, and the service layer before you even understand the failure.
TCA looks very different from that vantage point. One pattern, enforced by the type system. State only changes in reducers. You can literally log every action that fired in the session and replay it. Tests are deterministic. New developers can learn the pattern once and apply it everywhere.
For teams who have lived through two years of inconsistently architected MVVM, that is a real value proposition.
There's also an intellectual appeal to the architecture. It is principled, which feels satisfying to think about. Pure functions, immutable state, explicit side effects. These ideas come from functional programming and they are good ideas. Developers who encounter them for the first time, especially through Point-Free's thorough teaching, often have an "I wish I had known this earlier" reaction.
When you should use it
TCA earns its complexity at a specific scale.

If you have a large app with highly complex state, many developers working in parallel, or strict testing requirements for business-critical logic, TCA is a serious contender. Apps where "who changed this?" is a daily question benefit most from the architectural visibility TCA provides. Fintech, healthcare, e-commerce checkout logic, anything where a state bug has a real cost. That's where the strictness earns its keep.
If you're building a team that will work on this codebase for years and needs to enforce consistency across all features, TCA's opinionation is a feature rather than a constraint.
When you probably don't need it
Most apps are not enterprise scale. Most have one or two developers. Most features are not that complex.
A settings screen does not need a Reducer. A user profile view does not need PresentationState. A list view that fetches items and displays them does not need an Action enum with a dozen cases.
If you are working solo or in a team of two or three, the consistency benefits of TCA largely don't apply because you already know what every screen looks like. You built them. The testing benefits are real but achievable with @Observable classes and well-structured dependencies without the TCA overhead.
If you are prototyping, the cost-to-value ratio is terrible. TCA infrastructure takes time to set up, and prototypes need to move fast.
If you are starting an app and you genuinely don't know if it will grow large, starting with plain @Observable and a clean service layer is the sensible default. You can migrate complex features into TCA later if you need to. Going the other direction is much harder.
The one thing worth borrowing even if you don't adopt it
TCA's dependency management, packaged as the standalone swift-dependencies library, is universally useful regardless of what architecture you use.
Anything your app calls that has a side effect, whether it's a network call, a database, the system clock, or a UUID generator, gets wrapped in a protocol-like struct that you inject through the environment. In production you use the live implementation. In tests you substitute a controlled one.
// Declare the dependency
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}
// Use it
@Dependency(\.apiClient) var apiClient
// Override in tests
withDependencies {
$0.apiClient = .mock
} operation: {
// run your test
}
This pattern makes async side effects testable and predictable. It's not specific to TCA. You can use swift-dependencies in a plain SwiftUI app with @Observable ViewModels, and it will make your tests significantly cleaner.
So, is it really that good?
For the right project, yes. TCA is not overrated for what it targets. The testing tools are excellent. The traceability is real. For large apps with many contributors and highly complex state, it provides structure that MVVM consistently fails to maintain at scale.
But it is also fundamentally heavy, and not in a way that scales down gracefully. The learning curve isn't steep because developers are slow. It's steep because the architecture forces a mental model shift that takes time to internalize, and the library changes enough that you have to re-internalize parts of it every year or two.
The mistake most developers make with TCA is treating it as a default rather than a specialized tool. Point-Free teaches it well, the functional programming ideas are intellectually satisfying, and it becomes easy to reach for it on every project. That's where things go wrong.
Before you start, ask one question: does this app have the kind of state complexity and team coordination problems where TCA's guardrails will prevent real bugs? Or are you adding it because the architecture is interesting?
The answer usually makes the decision for you.