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
| MVVM | TCA | |
|---|---|---|
| Learning curve | Low | High |
| Boilerplate | Low | Medium–High |
| Testability | Good | Excellent |
| Feature composition | Manual | Built-in |
| Side effect management | Ad-hoc | Structured |
| Debugging | Standard | Time-travel capable |
| Team adoption | Easy | Requires buy-in |
| iOS version requirement | iOS 17+ for @Observable | iOS 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.