Skip to content
All essays

Mobile architecture

TCA vs MVVM in SwiftUI: who owns the state

A team decision, not a style preference. Since Observation, both sides render efficiently; what still separates TCA and MVVM is who may mutate state, how effects are represented, what a test has to prove, and what the team pays to learn, upgrade and one day leave the library.

14 min read

The search results for "TCA vs MVVM" are mostly tutorials that build the same counter twice and declare a winner. That is not the decision a team faces. A team faces it in a planning meeting, with a real codebase, a hiring plan and a feature that keeps producing bugs, and the question is whether to put a third-party architecture library at the center of how everyone writes SwiftUI.

I have run 500+ technical interviews from the hiring side over 15 years in mobile, and this question comes up in two places: in architecture rounds, and in the design reviews that come after people are hired. In both, the strong answers start from the same place my book Controlled Change starts its chapter on state: "Most mobile architecture arguments are state arguments in disguise." This article runs the TCA or MVVM decision through that lens, for a team rather than for an interview.

  • The short answer: MVVM with Observation for most features; a reducer, and TCA if the team commits to it, for the features where transitions and effects are the product.
  • What actually differs in 2026: the mutation path, how effects are represented, what tests must prove, and who carries the library's learning and upgrade cost.
  • What to decide before adopting TCA: which features use it, which package it lives in, and how a feature leaves it.

A two-way decision, not the five-pattern question

Two pages on this site sit next to this one, and they answer different questions. MVVM, MVI, TCA, Redux or VIPER? is the interview answer across five patterns: name the pressure, then the pattern. SwiftUI vs UIKit: architecture is state ownership is the essay on why ownership comes before any pattern name. If you are preparing for an architecture round, start with the first. If your team still argues about where state lives, start with the second.

This page is narrower and more practical. Your team writes SwiftUI, has ruled out VIPER and Redux, and is choosing between view models built on Observation and the Composable Architecture (TCA), the library from Point-Free. That is a decision with a budget, a training plan and an exit, so it is treated as one here.

What changed when both sides got Observation

Older comparisons spend most of their length on boilerplate and rendering cost. Much of that is out of date.

On the MVVM side, Apple's Observation framework arrived with iOS 17, iPadOS 17, macOS 14, tvOS 17 and watchOS 10. Apple's migration guide states the change that matters for architecture: with the @Observable macro, SwiftUI updates a view only when an observable property that the view's body reads changes, while an ObservableObject updates its views when any published property changes. A view model no longer needs @Published on every property or @StateObject to own it; @State and @Bindable work with it directly.

On the TCA side, Point-Free released Observation support in January 2024, with version 1.7, and wrote that view stores "and a flurry of related concepts, are no longer needed." For older systems the library ships a backport called Perception, and views on iOS 16 and earlier wrap their content in WithPerceptionTracking. The library's current package manifest declares iOS 16 as its minimum.

So the useful 2026 comparison is not about who renders faster or who types less. Both now track what a view reads. What still separates them is who may change state, how work that touches the outside world is represented, and what the team pays for the answer.

Who owns the state in each

In MVVM with Observation, a reference type owns the feature's mutable state and changes it inside its own methods. The view reads properties and calls methods. Effects run where the method runs them, usually in an async function on the main actor:

Swift
import Foundation
import Observation

struct Recipient: Equatable, Sendable { let id: String }
struct Receipt: Equatable, Sendable { let id: String }
struct TransferFailure: Error, Equatable, Sendable { let message: String }

// MVVM with Observation: a reference type owns the state and mutates it in methods.
@MainActor
@Observable
final class TransferModel {
    enum Phase: Equatable { case editing, submitting, sent(Receipt), failed(String) }

    var amount: Decimal = 0
    var recipient: Recipient?
    private(set) var phase: Phase = .editing

    @ObservationIgnored
    private let send: @Sendable (Recipient, Decimal) async throws -> Receipt

    init(send: @escaping @Sendable (Recipient, Decimal) async throws -> Receipt) {
        self.send = send
    }

    var canSubmit: Bool { recipient != nil && amount > 0 && phase == .editing }

    func submit() async {
        guard canSubmit, let recipient else { return }
        phase = .submitting
        do {
            phase = .sent(try await send(recipient, amount))
        } catch {
            phase = .failed("Transfer failed")
        }
    }
}

This is clear and short, and every Apple engineer can read it. Its weakness is not the pattern; it is that nothing structural stops the model from growing. Controlled Change describes the disciplined version: a ViewModel "owns feature-session state and calls explicit capabilities," and does not become a container for navigation, networking, analytics and database code. When it does, the book's advice is to split ownership, not to add a layer on top.

In TCA, state is a value owned by a store, and the only way to change it is to send an action into a reducer. The reducer mutates the state and returns effects as values; the library runs them and feeds their results back as new actions. The shape, modeled here in plain Swift without the library:

Swift
// Recipient, Receipt and TransferFailure as in the MVVM example.
// The reducer shape, modeled in plain Swift: state is a value, every change
// enters as an action, and work is returned as a description, not performed.
struct TransferFeature {
    struct State: Equatable, Sendable {
        var amount: Decimal = 0
        var recipient: Recipient?
        var phase: Phase = .editing
        enum Phase: Equatable, Sendable { case editing, submitting, sent(Receipt), failed(String) }
    }

    enum Action: Equatable, Sendable {
        case amountChanged(Decimal)
        case recipientChanged(Recipient?)
        case submitTapped
        case response(Result<Receipt, TransferFailure>)
    }

    enum Effect: Equatable, Sendable {
        case none
        case send(Recipient, Decimal)
    }

    func reduce(into state: inout State, action: Action) -> Effect {
        switch action {
        case let .amountChanged(amount):
            state.amount = amount
            return .none
        case let .recipientChanged(recipient):
            state.recipient = recipient
            return .none
        case .submitTapped:
            guard state.phase == .editing, state.amount > 0,
                  let recipient = state.recipient else { return .none }
            state.phase = .submitting
            return .send(recipient, state.amount)
        case let .response(.success(receipt)):
            state.phase = .sent(receipt)
            return .none
        case .response(.failure):
            state.phase = .failed("Transfer failed")
            return .none
        }
    }
}

// The test the shape makes cheap: a submit after the recipient changed
// must carry the new recipient. No view, no clock, no network.
func submitUsesLatestRecipient() -> Bool {
    let feature = TransferFeature()
    var state = TransferFeature.State(amount: 50, recipient: Recipient(id: "old"))
    _ = feature.reduce(into: &state, action: .recipientChanged(Recipient(id: "new")))
    let effect = feature.reduce(into: &state, action: .submitTapped)
    return effect == .send(Recipient(id: "new"), 50) && state.phase == .submitting
}

This is the trade in one picture. The reducer version has more vocabulary, and in exchange every transition is a named case and every effect is visible in a return value. Controlled Change names the failure each side is prone to: god ViewModels and effects that mutate state directly on one side; giant state and action types and reducers that perform I/O on the other. It also names what both must still get right: effects need identity and lifetime, and a durable submission must not be cancelled because a view disappeared, whichever pattern hosts it.

One more line from the book belongs in this comparison, from its chapter on Swift concurrency: "Observation is a projection mechanism; it should not grant arbitrary mutation across the graph." A view model with public setters and a store with a single mutation path are two ends of that rule. Most teams land between them on purpose.

What a test has to prove

Testability is the argument teams cite most for TCA, and it is a real difference, but a different one from what people expect.

Both patterns can be tested without a view. The view model above takes its effect as a closure, so a test can script success, failure and ordering. Point-Free's own Dependencies library, which TCA uses internally, also works in plain @Observable models; its README shows exactly that. Controllable clocks and scripted clients are not a TCA monopoly.

What TCA adds is the TestStore, and its default is strict. The library's testing article says it "requires you to exhaustively prove how the entire system of your feature evolves over time": every state change asserted, every effect's result received, every effect finished before the test ends. For a payment or booking flow, that strictness catches the effect nobody expected to still be running. For a large composed feature it can become noise, which is why the library also offers non-exhaustive testing by setting exhaustivity to .off.

The question for a team is which kind of test it wants on which feature. Exhaustive tests pin behavior tightly and break on harmless refactors; outcome tests survive refactors and can miss a stray effect. On the transfer above, the test that matters is a submit after the recipient changed must use the new recipient. The reducer shape makes that three lines. In the view model it is possible but awkward, because the transition is hidden inside submit(). That difference, on that kind of feature, is the testability argument worth having.

Onboarding and the people cost

Architecture is chosen once and read every day by everyone, including people who have not been hired yet. A view model built on Observation uses language and framework features any Apple engineer already knows. TCA brings its own vocabulary: reducers and their composition, scoped stores, effects, the dependency system, navigation tools, shared state and the test store. None of it is exotic, and Point-Free documents it thoroughly, but it is a learning curve that every engineer who touches those features has to climb.

Controlled Change lists that cost plainly among TCA's failure modes: "framework coupling, learning curve, over-modeling trivial UI, difficult partial adoption when conventions are inconsistent." Its strong fit is equally specific: "teams willing to adopt its model deeply." The second half of that sentence is the onboarding test. A small, stable team building effect-heavy flows can absorb the curve once and gain from it for years. A large team with regular turnover building mostly forms and lists pays the curve again with every hire.

From the hiring side there is one more angle. Not every strong iOS candidate has worked in TCA, so a TCA codebase either narrows the pool of people who are productive in their first weeks or lengthens those weeks for everyone else. That is not a reason to refuse it. It is a line for the decision record.

A third-party dependency with its own release clock

MVVM on Observation has no dependency beyond the SDK you already ship with. TCA is an MIT-licensed library maintained by Point-Free, and adopting it means adopting its release schedule. Its package manifest depends on twelve other Point-Free packages, among them Dependencies, Perception, Sharing, Case Paths and Navigation, plus Apple's swift-collections and swift-syntax, which its macros need.

The upgrade path is visible in the library's own history. Version 1.5 changed how scoped stores hold state, which made scoping along computed properties a performance concern, as the library's performance article explains. Version 1.7 replaced view stores with Observation. As of this writing the releases page shows the library still on its 1.x line, with 1.26 the newest minor, and the manifest already defines a ComposableArchitecture2Deprecations package trait that, in its own description, lets a project "remain ready for Composable Architecture 2.0." None of this is a criticism; deprecations staged ahead of a major version are what a careful maintainer does. It is work your team schedules, though, and it touches every feature that imports the library.

Two decisions bound that cost. The first is where the dependency lives. If the library sits in the shared domain package, every feature compiles against it and migrates with it, including the ones that never use a reducer. Keep it in the feature packages that need it. The second is the exit, and Controlled Change is specific about it: "Because the coupling is deep, the exit must be planned at adoption: keep public feature contracts free of framework types so a feature can leave without its callers changing." In practice, the surface other modules see looks like this, whichever pattern sits behind it:

Swift
// The feature's public surface: plain Swift types only. Callers never see a
// Store, a reducer, an action enum or an observable model.
public struct TransferRequest: Sendable {
    public let presetRecipientID: String?
    public init(presetRecipientID: String?) { self.presetRecipientID = presetRecipientID }
}

public enum TransferOutcome: Sendable, Equatable {
    case sent(receiptID: String)
    case cancelled
}

@MainActor
public protocol TransferEntry {
    func start(_ request: TransferRequest, onFinish: @escaping @MainActor (TransferOutcome) -> Void)
}

If a feature's callers only ever see that, rewriting it from a reducer to a view model, or across a TCA major version, is a local change. If they see a Store, it is not.

Performance at scale

Neither pattern is slow, and neither is fast by default. Each has its own way to waste work, and both documented failure modes are about what you route through the system.

TCA's performance article names four pitfalls. Sending an action is not as lightweight as calling a method on a class, so shared logic belongs in helper methods, not in actions sent to yourself. Reducers run on the main thread, so CPU-heavy work belongs in an effect. High-frequency actions, dozens per second from a slider or a scroll position, should be avoided unless the logic needs them. And scoping a store along a computed property can recompute that property many times; scope along stored child state instead.

Observation has the mirror-image trap. It tracks the properties a view actually reads, so a computed convenience property that reads five stored properties makes the view depend on all five. A single app-wide @Observable store that every screen reads through broad accessors invalidates far more than anyone intended. The fix is the same shape on both sides: narrow what each view reads, and keep high-frequency values local to the view that needs them.

At scale, the difference that remains is predictability. In TCA every change passes through one path you can log and measure. In MVVM changes can come from any method, which is cheaper per change and harder to audit. Measure on your own hardest screen with Instruments before letting either claim decide.

When each fits

Controlled Change compares the patterns by pressure rather than preference. Adapted to this two-way decision:

Pressure on the featureMVVM with ObservationTCA
Simple screens: settings, static forms, listsStrongest fit; least codeCostly; over-modeling trivial UI is a named failure
Many transitions and effects: checkout, booking, multi-step syncWorkable with an explicit state machine inside the modelStrong fit; transitions and effects are explicit by construction
Proving effects in testsManual: inject dependencies, assert outcomesIntegrated: exhaustive TestStore by default, non-exhaustive when chosen
Onboarding and turnoverUses what every Apple engineer knowsA curve each new engineer climbs
Dependency and upgradesApple SDK onlyLibrary release schedule; a 2.0 is being prepared
Leaving laterCheapCheap only if public contracts stay framework-free

The book's fictional reference system, Atlas, shows the hybrid that most teams end up with: search uses local state and a ViewModel, booking uses an explicit reducer and state machine, and the application shell owns navigation and session. Its rule for consistency is the one to adopt: it comes "from shared invariants for ownership, effects, and authority, not from one acronym." A reducer does not require TCA, either. A hand-written reducer like the one above gives a single feature explicit transitions without a dependency; TCA becomes the better choice when many features need that discipline and the team wants the effects, dependencies, navigation and testing tools that come with it.

Avoid mandating either everywhere. Controlled Change's guidance is to standardize the decision process, integration boundaries, lifecycle rules and test expectations, and to permit bounded variation where complexity differs. Write the rule: which feature classes use which shape, and why.

Decide it with a pilot, not a debate

The way out of a long TCA versus MVVM thread is a short experiment. Pick one representative feature with real state pressure, not a counter, and build the hard part both ways: the response lost after submit, the deep link into a half-finished flow, the edit that arrives while a save is in flight. The book's migration path for a pattern is the frame: pilot on a non-critical feature, define success and exit criteria in advance, keep public contracts framework-neutral, and if the pattern fails, remove it before shared contracts depend on its types.

Then record the outcome as a decision with an owner: the features in scope, the package the library lives in, the version you adopt and who schedules its upgrades, the test style you expect, and the trigger that would make you revisit it. If the team writes design docs, the technical design doc template has the sections for it; if the decision changes an existing app's structure, changing the architecture without stopping the release train covers moving one slice at a time.

The book closes its pattern chapter with the line I would put at the top of the record: "Winning the pattern debate is not the job." The job is a choice the next engineer can read, test and, if needed, undo.

What I listen for when a team proposes TCA or MVVM

TopicMid-levelSeniorStaff
Why this pattern"TCA is the modern way" or "MVVM is standard."Names the features and the bugs it should prevent.Names the pressure per feature class, the simpler option tried, and a written rule for the hybrid.
State"The view model holds the state."One owner per feature; derived values computed, not stored.Who alone may mutate, which states are impossible by construction, and which effect lifetimes outlive the view.
Tests"TCA is more testable."Tests transitions without a view in either pattern.Chooses exhaustive or outcome tests per feature and says what each one would miss.
CostNot mentioned.Learning curve.Onboarding per hire, package placement, upgrade owner, and an exit that callers never notice.

The staff column does not prefer one pattern. It is more precise about who pays for the choice and when.

Questions engineers ask about TCA vs MVVM

Is TCA better than MVVM for SwiftUI?

Neither is better in general. TCA gives value-type state, explicit actions, effects returned from a reducer, a dependency system and exhaustive tests as one integrated library, which pays off on features with rich state and many effects. MVVM with Observation uses only Apple's frameworks, is quicker to learn and cheaper to leave, and fits forms, lists and workflows with straightforward transitions. The useful question is which one makes this feature's failures easier to prevent and test, at a cost this team can carry.

Is TCA still worth it now that SwiftUI has @Observable?

Observation removed the old performance and boilerplate arguments on both sides: TCA integrated it in version 1.7 and dropped view stores, and plain view models now get property-level tracking without @Published. What Observation does not give you is TCA's single mutation path through actions, effects as returned values, its dependency system or TestStore. If your team needs those on its hardest features, TCA still earns its place there. If it does not, Observation makes plain models good enough for most screens.

Can you use TCA and MVVM in the same app?

Yes, and many teams should. Write the rule down: which kinds of feature use a reducer and which use an observable model, and keep the boundary between them a framework-free interface. Put the library dependency in the feature packages that use it, not in the shared domain package, so features that never use it do not compile against it or migrate with it.

How hard is it to migrate away from TCA?

It depends on a decision made at adoption. If callers only see plain Swift types for a feature's inputs and outcomes, the feature can be rewritten as an observable model without its callers changing. If Store, actions or reducers leak into shared packages and public contracts, leaving touches every caller. The same check tells you how expensive the next TCA major version will be.

Share this essay