All posts
Swift Architecture SwiftUI TCA MVVM

TCA vs MVVM in 2026: A Pragmatic Comparison

Architecture debates in the iOS community often generate more heat than light. TCA has a passionate following, MVVM is the default that everyone knows, and both camps can sound like they’re selling something. Let me try a different angle: when does each one actually make your life easier?

What They’re Really Solving

MVVM separates UI from business logic. The ViewModel holds state and exposes it to the View. Simple contract, minimal ceremony.

TCA (The Composable Architecture by Point-Free) goes further: it makes state mutations explicit and traceable by routing every change through a Reducer via Action values. Side effects are isolated in Effect. The whole thing is designed to compose — small features combine into bigger ones.

They’re not competing on the same axis. MVVM is about separation of concerns. TCA is about making the full lifecycle of state — mutations, effects, navigation — auditable and testable.

MVVM: What It Looks Like in 2026

With @Observable (iOS 17+), MVVM is cleaner than ever:

@Observable
class ProfileViewModel {
    var user: User?
    var isLoading = false
    var errorMessage: String?

    private let service: UserService

    init(service: UserService = .live) {
        self.service = service
    }

    func loadUser(id: String) async {
        isLoading = true
        defer { isLoading = false }
        do {
            user = try await service.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct ProfileView: View {
    @State private var vm = ProfileViewModel()

    var body: some View {
        Group {
            if vm.isLoading {
                ProgressView()
            } else if let user = vm.user {
                Text(user.name)
            }
        }
        .task { await vm.loadUser(id: "123") }
    }
}

This is readable, fast to write, and covers 80% of real apps without friction.

TCA: What It Looks Like in 2026

The same feature in TCA:

@Reducer
struct ProfileFeature {
    @ObservableState
    struct State: Equatable {
        var user: User?
        var isLoading = false
        var errorMessage: String?
    }

    enum Action {
        case loadUser(id: String)
        case userResponse(Result<User, Error>)
    }

    @Dependency(\.userClient) var userClient

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case let .loadUser(id):
                state.isLoading = true
                return .run { send in
                    await send(.userResponse(
                        Result { try await userClient.fetchUser(id) }
                    ))
                }
            case let .userResponse(.success(user)):
                state.isLoading = false
                state.user = user
                return .none
            case let .userResponse(.failure(error)):
                state.isLoading = false
                state.errorMessage = error.localizedDescription
                return .none
            }
        }
    }
}

struct ProfileView: View {
    let store: StoreOf<ProfileFeature>

    var body: some View {
        Group {
            if store.isLoading {
                ProgressView()
            } else if let user = store.user {
                Text(user.name)
            }
        }
        .task { store.send(.loadUser(id: "123")) }
    }
}

More lines, more structure. But notice what you get: every state change is a named Action. You can log every mutation. You can replay actions in tests. You can time-travel debug.

Where MVVM Wins

Small to medium apps. If your app has 10–20 screens without deep cross-feature dependencies, MVVM gets the job done with far less ceremony. Onboarding new devs is fast — everyone knows the pattern.

Prototyping and MVPs. Speed matters. MVVM lets you move fast and refactor later.

Teams without prior TCA experience. TCA has a real learning curve. Dependency, Effect, Scope, ifLet, navigation stacks — the surface area is large. Introducing it to a team mid-project is a significant investment.

When @Observable is available. With iOS 17+, @Observable removes a lot of the boilerplate that used to make MVVM feel fragile. The gap with TCA has narrowed.

Where TCA Wins

Deep feature composition. TCA was built to compose. A TabFeature that contains a SearchFeature and a ProfileFeature, each with their own state, effects, and navigation — TCA handles this cleanly. MVVM tends to generate coordinator spaghetti at this scale.

Complex side effects and cancellation. TCA’s Effect system — with .cancel(id:), .merge, .concatenate — is genuinely better at managing async complexity than ad-hoc Task juggling in a ViewModel.

Testability without mocks. The @Dependency system lets you replace live dependencies with test values in a single line. Testing a reducer means sending actions and asserting state — no XCTExpectation, no timing.

@Test
func loadingUserUpdatesState() async {
    let store = TestStore(initialState: ProfileFeature.State()) {
        ProfileFeature()
    } withDependencies: {
        $0.userClient.fetchUser = { _ in User(id: "1", name: "Claudio") }
    }

    await store.send(.loadUser(id: "1")) {
        $0.isLoading = true
    }
    await store.receive(.userResponse(.success(User(id: "1", name: "Claudio")))) {
        $0.isLoading = false
        $0.user = User(id: "1", name: "Claudio")
    }
}

This kind of test — exhaustive, deterministic — is hard to replicate in MVVM without significant discipline.

Navigation as state. TCA’s navigation tools (@Presents, NavigationStackStore) model navigation as part of the app state. Deep links, state restoration, and navigation testing become tractable problems.

The Honest Trade-off

MVVMTCA
Learning curveLowHigh
BoilerplateLowMedium–High
TestabilityGoodExcellent
Feature compositionManualBuilt-in
Side effect managementAd-hocStructured
DebuggingStandardTime-travel capable
Team adoptionEasyRequires buy-in
iOS version requirementiOS 17+ for @ObservableiOS 16+

My Take

Use MVVM when you want to move fast, the team is mixed-experience, or the app is straightforward. Use TCA when you have a feature-heavy app, complex async flows, or a team that can absorb the upfront cost in exchange for long-term maintainability.

The worst outcome is adopting TCA for a simple app and spending more time fighting the architecture than shipping features — or using MVVM on a large app and drowning in coordinator chains and shared state bugs.

Both are good tools. The question is whether the problem you have is the problem they solve.