Why I stopped fighting state management and let TCA win
All Articles
· iOS Swift TCA Architecture

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.

Interactive TCA Flow

View

0

Reducer

⚙️
State mutation logic

Environment

☁️
Dependencies

Click a button above to observe the unidirectional data flow.

This is the part that made TCA click for me. The Effect doesn’t update state directly. It can’t. It sends a new Action, which goes through the Reducer again. There’s only one place state ever changes, and it’s always synchronous, always in the Reducer.

The Testing Story

Here’s where TCA goes from “interesting” to “I’m never going back.”

Remember that search feature? Here’s a test:

@Test
func searchDebounceAndResponse() async {
    let clock = TestClock()
    let store = TestStore(
        initialState: SearchFeature.State()
    ) {
        SearchFeature()
    } withDependencies: {
        $0.continuousClock = clock
        $0.searchClient.search = { query in
            [SearchResult(title: "Result for \(query)")]
        }
    }

    await store.send(.queryChanged("swift")) {
        $0.query = "swift"
    }

    await clock.advance(by: .milliseconds(300))
    await store.receive(.debounceCompleted) {
        $0.isLoading = true
    }

    await store.receive(.searchSucceeded([
        SearchResult(title: "Result for swift")
    ])) {
        $0.isLoading = false
        $0.results = [SearchResult(title: "Result for swift")]
    }
}

This test is exhaustive within the feature boundary. If the Reducer changes state in a way you didn’t assert, the test fails. If it fires an Effect you didn’t expect, the test fails. If it doesn’t fire an Effect you expected, the test fails. You’re not just checking a happy path—you are locking down the state machine.

In practice, this style of testing dramatically reduces “impossible state” bugs. It does not replace integration and end-to-end tests, but it makes local feature behavior far more reliable.

The Honest Downsides

I’m not going to pretend TCA is perfect. Here’s what’s hard:

The learning curve is real. If your team hasn’t encountered Reducers or functional programming patterns before, budget two to three weeks of slowdown before productivity returns. The concepts aren’t complex — it’s re-wiring your intuition from “mutate state directly” to “describe what happened and let the Reducer decide” that takes time.

Boilerplate for simple screens. A static “About” page doesn’t need a Reducer. I still stub one in sometimes out of habit, and then immediately delete it. My rule: if the screen has no async work and no shared state, skip TCA.

Parent-child action flow can get mentally expensive. One of TCA’s strengths is that parent and child reducers can both react to the same action. One of its weaknesses is that this can make behavior harder to trace. If a child emits an action and the child, parent, and navigation layer all respond, you have to understand reducer composition and ordering to know what really happened. My rule is simple: if multiple reducers are reducing on the same action, it should represent a real domain event, not a convenience shortcut for coordination.

Third-party dependency. You’re coupling your architecture to Point-Free’s library. They’ve been excellent stewards — updates are frequent, the API is stable, and the documentation is thorough. But it’s still a dependency. Acknowledge the risk, especially in professional environments.

Xcode previews can stall. Complex Stores with many dependencies sometimes make SwiftUI previews sluggish. I work around this with lightweight preview-specific Stores, but it’s friction you should know about before you’re on a deadline.

When to Actually Use It

Here’s my honest framework for choosing:

  • Small, simple app: MVVM is fine. Don’t over-engineer.
  • Team of 2-4, moderate complexity: TCA starts to shine. The enforced patterns mean new team members get productive fast.
  • Team of 5+, complex app with shared state: TCA is a no-brainer. The consistency across features alone justifies it.
  • Existing large codebase: Adopt incrementally. TCA plays well with UIKit and traditional MVVM. You can migrate feature by feature.

Once familiar with TCA, going back to a non-TCA codebase can feel like debugging in the dark. The explicitness is addictive.

Final Thought

TCA did not make app development simpler for me. It made complexity visible. That turned out to be the bigger win.

Once the rules were explicit, the codebase stopped feeling like a collection of clever ViewModels and started feeling like a system I could actually reason about. That’s why I stopped fighting state management and let TCA win.