Why I stopped fighting state management and let TCA win
A practical journey from spaghetti ViewModels to The Composable Architecture, with an interactive visualization of how it actually works under the hood.
Imagine building an app where every ViewModel knows about three other ViewModels. Tapping a button on the Settings screen might sometimes cause the Home feed to reload, and nobody can explain why. The test suite can easily become 40% mocks, and half the mocks might be wrong.
This kind of complexity leaves developers genuinely afraid to touch anything in the codebase.
This is not a TCA tutorial. There are plenty of those. This is the story of why I switched, what actually clicked, and — because some concepts only make sense when you can see the data flow happen in real time — an interactive visualization I built that lets you do exactly that.
The Problem Nobody Talks About
Here’s what most architecture articles skip: the real problem isn’t your architecture. It’s that your architecture doesn’t enforce anything.
MVVM doesn’t stop you from injecting a network client directly into your View. It doesn’t stop you from mutating shared state from two different ViewModels on two different threads. It doesn’t stop you from firing a network request inside viewDidAppear and hoping the response arrives before the user navigates away.
MVVM is a suggestion. TCA is a constraint — enforced by the compiler.
When I first read TCA’s Reducer protocol, I thought it was over-engineered. A counter example? Really? The genius isn’t in the counter. It’s in what happens when you have 47 features that all need to talk to each other, and every single interaction follows the exact same pattern.
How TCA Actually Works (Without the Jargon)
Forget “unidirectional data flow” for a second. Here’s the deal in plain terms:
Your view can’t do anything. It can only describe what happened. “The user tapped increment.” “The user pulled to refresh.” “The view appeared.” These are Actions—just enum cases. Your view has zero access to business logic.
Your Reducer is the brain. It receives the action, looks at the current state, decides what the new state should be, and optionally kicks off some work (an Effect). The key benefit is predictability: state transitions happen in one place, and side effects are modeled explicitly. No hidden side channels. No “well, sometimes it also calls this delegate.”
Effects are the hands. Need to hit an API? That’s an Effect. Need to read from UserDefaults? Effect. Need to start a timer? Effect. Effects do their work and feed results back into the system as new Actions. The Reducer never talks to the outside world directly.
Here’s a common feature example—a search screen. In traditional MVVM, one might have a ViewModel with a searchText published property, a debounce operator, a network call, error handling, and probably a delegate to tell the parent when a result was selected. That’s 150+ lines of tangled reactive code.
In TCA:
@Reducer
struct SearchFeature {
@ObservableState
struct State: Equatable {
var query = ""
var results: [SearchResult] = []
var isLoading = false
var error: String?
}
@CasePathable
enum Action {
case queryChanged(String)
case debounceCompleted
case searchSucceeded([SearchResult])
case searchFailed(String)
case resultTapped(SearchResult)
}
@Dependency(\.searchClient) var searchClient
@Dependency(\.continuousClock) var clock
enum CancelID { case debounce, search }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .queryChanged(query):
state.query = query
state.error = nil
guard !query.isEmpty else {
state.results = []
return .merge(
.cancel(id: CancelID.debounce),
.cancel(id: CancelID.search)
)
}
return .run { send in
try await clock.sleep(for: .milliseconds(300))
await send(.debounceCompleted)
}
.cancellable(id: CancelID.debounce, cancelInFlight: true)
case .debounceCompleted:
state.isLoading = true
return .run { [query = state.query] send in
do {
let results = try await searchClient.search(query)
await send(.searchSucceeded(results))
} catch {
await send(.searchFailed(error.localizedDescription))
}
}
.cancellable(id: CancelID.search, cancelInFlight: true)
case let .searchSucceeded(results):
state.isLoading = false
state.results = results
return .none
case let .searchFailed(errorMessage):
state.isLoading = false
state.error = errorMessage
return .none
case .resultTapped:
return .none // parent handles navigation
}
}
}
}
Read that top to bottom. Every possible thing that can happen is right there. The debounce logic, the API call, the error handling, and cancellation for both pending debounce work and in-flight searches when input changes. No Combine chains. No callback hell. No “where does this state get mutated?” You can trace every state change by reading the switch cases.
See It For Yourself
I built this interactive demo to make the flow tangible. Click the buttons below and watch what happens.
The increment/decrement buttons show the simple path: Action → Reducer → State update. But click “Random Number Effect” and watch the full cycle: the Action hits the Reducer, which fires an Effect to the Environment, which does async work, and then feeds a new Action back into the system with the result.
The TCA loop
View
Reducer
The only place state changes
Tobias Reinsch