---
title: "State and architecture in SwiftUI: from Zustand and Redux to @Observable and TCA"
description: "How to manage state in SwiftUI coming from Zustand and Redux: MVVM with @Observable as the default, when TCA justifies its learning curve, dependency injection with swift-dependencies or Factory, and why to modularize into packages from day one."
author: Ramón Chancay
date: 2026-09-04
lang: en
tags: [SwiftUI, Architecture, TCA, Observable, Zustand, Redux, Swift Package Manager]
canonical: https://www.ramonchancay.me/blog/state-and-architecture-from-zustand-to-observable-and-tca
---

# State and architecture in SwiftUI: from Zustand and Redux to @Observable and TCA

In React Native the conversation about state is already settled for most teams: local state with hooks, server state with TanStack Query, global state with Zustand or Redux Toolkit depending on the size of the app. In SwiftUI the conversation exists too, but under other names and with a debate the community still hasn't closed: whether you need a ViewModel at all, or whether the view can talk to the model directly. This post gives my position, based on projects that started small and grew: `@Observable` with MVVM as the default, TCA only when the project justifies its learning curve, dependencies injected through a library from day one, and the code split into Swift Package Manager packages before the project file becomes a problem.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> TL;DR <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>An <code>@Observable</code> class is the replacement for a Zustand store: shared state, per-property observation and no selectors. One ViewModel per screen built on that class is the default that scales without ceremony.</li>
<li>TCA (The Composable Architecture) is Redux with effects, cancellation and exhaustive tests built in. It pays off when the team is large, the domain is complex and you are going to test every transition; for a mid-sized app, its learning curve costs more than it returns.</li>
<li>Inject dependencies from day one with swift-dependencies or Factory, and split the app into SPM packages by layer. Both habits are cheap at the start and very expensive to add later.</li>
</ul>
</div>
</details>

**In this article:**

- **The model** — [What replaces what](#what-replaces-what-the-state-map) · [@Observable as the default](#observable-the-store-that-ships-with-the-system)
- **The architecture** — [MVVM or MV](#mvvm-or-mv-the-discussion-you-are-going-to-have) · [When TCA justifies its learning curve](#when-tca-justifies-its-learning-curve)
- **The structure** — [Dependency injection](#dependency-injection-swift-dependencies-or-factory) · [Modularize with SPM](#modularize-into-packages-from-day-one)

## What replaces what: the state map

Before discussing architecture it helps to fix the vocabulary, because state in SwiftUI is spread across more places than in React, and each one has its own tool.

| Kind of state | React Native | SwiftUI |
|---|---|---|
| Local to the view (a toggle, a field) | `useState` | `@State` with a `struct` or a simple value |
| Shared between sibling views | Lift state + props | `@State` in the parent + `@Binding` in the children |
| Per screen (loading, form, errors) | `useReducer` or a custom hook | An `@Observable` ViewModel created by the screen |
| App-wide (session, cart, settings) | Zustand / Redux | An `@Observable` class injected with `.environment` |
| Server (network cache) | TanStack Query | No standard equivalent; covered in the [data post](/blog/data-in-swift-what-tanstack-query-did-for-you) |
| Persisted (preferences) | AsyncStorage + store | `@AppStorage` for simple values; SwiftData or GRDB for the rest |
| Navigation | Expo Router | `NavigationStack` with an observable path; covered in the [navigation post](/blog/swiftui-navigation-without-file-based-routing) |

The biggest difference from React Native is that global state doesn't need a library. `@Observable` ships with the system since iOS 17 and does what Zustand did for you: expose a mutable object whose changes re-evaluate only the views that read the affected properties.

## @Observable: the store that ships with the system

A Zustand store is an object with state and the functions that mutate it, and a component subscribes to a slice of it with a selector so it doesn't re-render on changes it doesn't care about. The Swift translation is a class marked with the `@Observable` macro:

```swift
import Observation

@Observable
final class CartStore {
    private(set) var items: [CartItem] = []
    var coupon: String?

    var total: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }

    func add(_ product: Product) {
        if let index = items.firstIndex(where: { $0.productId == product.id }) {
            items[index].quantity += 1
        } else {
            items.append(CartItem(product: product))
        }
    }

    func remove(productId: Product.ID) {
        items.removeAll { $0.productId == productId }
    }
}
```

And its use from a view, with no selector:

```swift
struct CartBadge: View {
    @Environment(CartStore.self) private var cart

    var body: some View {
        // This view is only re-evaluated when `items` changes.
        // A change in `coupon` doesn't touch it, with no selectors or memo.
        Text("\(cart.items.count)")
    }
}
```

The macro instruments every property to record which view read it during `body`. It is the behavior of a Zustand selector, but automatic and at property granularity. There is no `useShallow`, no `useStore((s) => s.items.length)`, no `React.memo`. There is also no `ObservableObject` with `@Published` and `objectWillChange`, which was the previous API and which you will still see in projects and examples: with that model, any change to any property re-evaluated every view subscribed to the object, and that was a frequent source of unnecessary re-evaluations before iOS 17.

Three practical decisions:

- **`private(set)` on whatever only the store may mutate.** The same way you exposed actions in Zustand instead of letting the component call `set` directly. The view reads `items` and calls `add`; it doesn't touch the array.
- **Inject it with `.environment(cartStore)` at the root** and read it with `@Environment(CartStore.self)`. One global store per domain (session, cart, settings), not one giant store with everything.
- **To write through a `Binding` to a store property** from a `TextField` or a `Toggle`, use `@Bindable var cart = cart` inside `body`, and from there `$cart.coupon`.

## MVVM or MV: the discussion you are going to have

The SwiftUI community has had a years-long debate over whether the ViewModel is needed. One side (MV, "Model-View") argues that SwiftUI is already a binding between model and view, that the property wrappers are the presentation layer, and that adding a ViewModel per screen is carrying ceremony inherited from UIKit. The other side (MVVM) argues that the view should have zero logic and that one testable object per screen is what keeps the project healthy when five people are touching it.

My position, having done both: **MVVM with `@Observable`, but lightweight**. One ViewModel per screen that:

- Exposes the state the view needs, already transformed (formatted strings, sorted lists, loading flags).
- Receives user actions as methods (`load()`, `submit()`, `delete(id:)`).
- Talks to repositories and stores, not to the network or the database directly.
- Doesn't import SwiftUI. If the ViewModel needs `import SwiftUI`, some presentation concern leaked where it doesn't belong.

```swift
@Observable
@MainActor
final class OrdersViewModel {
    private(set) var state: Loadable<[Order]> = .idle
    var query = ""

    private let repository: OrderRepository

    init(repository: OrderRepository) {
        self.repository = repository
    }

    var visibleOrders: [Order] {
        guard case .loaded(let orders) = state else { return [] }
        guard !query.isEmpty else { return orders }
        return orders.filter { $0.title.localizedCaseInsensitiveContains(query) }
    }

    func load() async {
        state = .loading
        do {
            state = .loaded(try await repository.fetchAll())
        } catch {
            state = .failed(error)
        }
    }
}
```

```swift
struct OrdersScreen: View {
    @State private var model: OrdersViewModel

    init(repository: OrderRepository) {
        _model = State(initialValue: OrdersViewModel(repository: repository))
    }

    var body: some View {
        List(model.visibleOrders) { order in
            OrderRow(order: order)
        }
        .searchable(text: Bindable(model).query)
        .overlay { if case .loading = model.state { ProgressView() } }
        .task { await model.load() }
    }
}
```

The MV argument I do accept: for a small view with two pieces of local state, a ViewModel is noise. The rule I use is that **the ViewModel appears when the screen has an async load, more than one derived state, or logic I want to test without SwiftUI**. A `Toggle` with a `@State` doesn't need a ViewModel; an orders list with search, filtering and deletion does.

What I avoid in both schools: views that call `URLSession` directly, ViewModels that know about `NavigationPath`, and global stores with UI properties (`isSheetPresented` doesn't belong in `CartStore`).

## When TCA justifies its learning curve

The Composable Architecture, by Point-Free, is Swift's answer to Redux, and if you come from Redux Toolkit you will recognize it immediately: a `State` (struct), an `Action` (enum with associated data), a `Reducer` that produces the new state and returns effects, and a `Store` that ties it all together. On top of that base it adds what you solved in Redux with middlewares and separate libraries: async effects as first-class values, cancellation by id, reducer composition for child screens, navigation modeled in state, and a `TestStore` that verifies every state transition and every effect exhaustively.

```swift
@Reducer
struct OrdersFeature {
    @ObservableState
    struct State: Equatable {
        var orders: [Order] = []
        var isLoading = false
        var query = ""
    }

    enum Action {
        case task
        case ordersResponse(Result<[Order], Error>)
        case queryChanged(String)
    }

    @Dependency(\.orderClient) var orderClient

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .task:
                state.isLoading = true
                return .run { send in
                    await send(.ordersResponse(Result { try await orderClient.fetchAll() }))
                }
            case .ordersResponse(.success(let orders)):
                state.isLoading = false
                state.orders = orders
                return .none
            case .ordersResponse(.failure):
                state.isLoading = false
                return .none
            case .queryChanged(let query):
                state.query = query
                return .none
            }
        }
    }
}
```

What it gives you in exchange for that structure:

- **Exhaustive tests.** `TestStore` fails if the state changes in a way the test didn't declare, or if an effect is left pending. It is the level of guarantee that in Redux demanded discipline and here is demanded by the framework.
- **Composition.** A child screen is a reducer embedded in the parent with `Scope`, with its state and actions included in the parent's. It scales to dozens of screens without any of them knowing about the others.
- **Navigation as state.** Sheets, alerts and pushes live in `State` as optionals or as a stack, and are tested like any other change.

What it costs:

- **The learning curve.** Macros, `Scope`, `IdentifiedArray`, `@Presents`, `StackState`, `Effect.run` with `send`. It takes a senior two or three weeks to write it fluently, and the rest of the team has to go through the same.
- **Real boilerplate.** A `Toggle` is an action, a case in the reducer and a line in the test. For simple screens, the ratio of code to value is poor.
- **A third-party dependency** at the heart of the app, with updates that sometimes require migrations.

My rule: TCA when **the team is three or more people, the domain has states with many transitions (checkout, onboarding with branches, editors), and tests for those transitions are a requirement, not a wish**. For everything else, MVVM with `@Observable`. And one fact that helps decide: the same people who build TCA publish swift-dependencies, swift-navigation and other libraries that work without TCA, so you can adopt their ideas without adopting the full framework.

```text
Do I need TCA?

  Team of 3+ and a domain with complex states?
     │
     ├── no ──► MVVM with @Observable
     │
     └── yes
          │
          Is testing every transition a requirement?
             │
             ├── no ──► MVVM with @Observable + swift-dependencies
             │
             └── yes ──► TCA
```

## Dependency injection: swift-dependencies or Factory

In React Native, dependency injection is rarely formalized: you import the API client, mock it with `jest.mock` in the tests and move on. In Swift there is no `jest.mock`: if a ViewModel builds a concrete client, the test uses the concrete client. That's why injection shows up on day one. You don't need a library for it (initializers that take protocols, closures or a hand-written factory work fine), but once the project goes past three screens it is worth systematizing, and the two libraries I use for that are these.

**swift-dependencies** (Point-Free, also usable without TCA). You declare each dependency as a key, provide a live implementation and a test one, and read it with `@Dependency` wherever you need it:

```swift
import Dependencies

struct OrderClient: Sendable {
    var fetchAll: @Sendable () async throws -> [Order]
}

extension OrderClient: DependencyKey {
    static let liveValue = OrderClient(
        fetchAll: { try await APIClient.shared.get("/orders") }
    )
    static let testValue = OrderClient(
        fetchAll: { [.fixture()] }
    )
}

extension DependencyValues {
    var orderClient: OrderClient {
        get { self[OrderClient.self] }
        set { self[OrderClient.self] = newValue }
    }
}
```

In a test, `withDependencies { $0.orderClient.fetchAll = { throw URLError(.notConnectedToInternet) } }` and the ViewModel receives that version without the production code knowing anything about it. One design decision worth copying: the dependency is a `struct` with closures, not a protocol. For the test you override only the closure that matters.

**Factory** (Michael Long). A container of registrations with a shorter syntax and lazy resolution:

```swift
import FactoryKit

extension Container {
    var orderRepository: Factory<OrderRepository> {
        self { RemoteOrderRepository(client: self.apiClient()) }
            .singleton
    }
}

// In the ViewModel
@Injected(\.orderRepository) private var repository
```

And in tests, `Container.shared.orderRepository.register { FakeOrderRepository() }`.

Which one to pick: swift-dependencies if you value strict test control (it fails if a dependency is used without being declared) and the affinity with TCA; Factory if you prefer something more direct, with scopes (singleton, cached, shared) and less ceremony. Both are better than the "singleton with `shared` plus an `init(client:)` for tests" pattern that we all write first and that stops scaling by the third screen.

## Modularize into packages from day one

In React Native, a monorepo with packages is a decision you make when the project grows. In Swift my recommendation is the opposite: **split into Swift Package Manager packages from the first commit**. Apple documents local packages as the way to modularize an app; the three reasons I do it from the start are these, and the first one is specific to Xcode.

The first is the project file. Every file you add to an Xcode target modifies `project.pbxproj`, and two people adding files on different branches produce merge conflicts in a format nobody wants to resolve by hand. An SPM package is described in `Package.swift` and takes its files from the file system: adding a file is adding a file.

The second is build times. Swift compiles per module, and a change in one module recompiles that module and the ones that depend on it, so a dependency graph that points in a single direction bounds what gets recompiled: with packages per layer, a change in the orders UI doesn't touch networking or persistence. It is not an automatic guarantee (the result depends on the graph, on the generics and macros that cross module boundaries and on the build configuration, and over-splitting can also make it worse), but it is the biggest lever I have found, and the [tooling post](/blog/ios-testing-and-tooling-for-seniors) explains how to measure it.

The third is visibility. Without modules, everything is `internal` and reachable from anywhere; the architecture holds by convention. With packages, `public` is an explicit decision, and a view can't import `URLSession` if the UI package doesn't depend on the networking one.

The minimal structure I use:

```text
MyApp/
├── App/                    ← Xcode target: only the @main and the composition
└── Packages/
    ├── Domain/             ← structs, enums, repository protocols. No dependencies.
    ├── Networking/         ← URLSession, DTOs, Codable. Depends on Domain.
    ├── Persistence/        ← SwiftData or GRDB. Depends on Domain.
    ├── DesignSystem/       ← colors, typography, base components. No dependencies.
    └── Features/
        ├── Orders/         ← views + ViewModels. Depends on Domain and DesignSystem.
        └── Checkout/
```

With a `Package.swift` in `Packages/` declaring each one as a target of a single package, or as separate packages if the project is large. The Xcode target ends up almost empty: the `@main`, the `.environment(...)` tree and the choice of live implementations for each dependency.

> In a project that started with a single target and grew for a year, the cost of splitting it into packages afterwards was several weeks of work spread across moving files, resolving dependency cycles nobody knew existed and making hundreds of types `public`. In the next project I did it on day one and it took an afternoon.

If you would rather never touch the `.pbxproj` at all, Tuist generates the project from a description written in Swift and removes the conflicts entirely; the [build and signing post](/blog/build-signing-app-store-what-eas-hid-from-you) covers it. But even without Tuist, packages already solve most of the problem.

## Frequently asked questions

### Does `@Observable` fully replace `ObservableObject` and `@Published`?

For new code targeting iOS 17 or later, yes. `ObservableObject` still exists for compatibility and for some Combine cases, but the performance and ergonomics of `@Observable` are better. If you inherit a project with `@StateObject` and `@ObservedObject`, you migrate class by class without changing the rest.

### Where does the "user is signed in" state live?

In a global `@Observable` store (`SessionStore`) injected at the root with `.environment`. The root view observes `session.user` and decides whether to show the login or the app. It is the equivalent of a React `AuthProvider`, with the difference that it doesn't need a context separate from the object.

### Can I mix TCA in one part of the app and MVVM in the rest?

Yes, and it is a reasonable way to adopt it: TCA in the complex feature (a checkout, an editor) and MVVM in the rest. A TCA `Store` can be created from any SwiftUI view. What it costs is maintaining two ways of doing the same thing on the team, so the boundary should be clear and documented.

### How do I share logic between screens without a giant ViewModel?

With stores per domain (`CartStore`, `SessionStore`) for what is global, and with injected use cases or repositories for the logic that several screens run. A ViewModel shouldn't call another ViewModel; both call the same repository.

### Do SPM packages make Previews or tests harder?

No. Each package can have its own test target, which in my experience runs faster than the tests in the app target because it compiles only that module and its dependencies. Previews work inside the package, with sample data from the same module, and for the same reason they are usually faster than in the full app.

## Conclusion

State in SwiftUI doesn't need a library for what Zustand used to do: `@Observable` is a store with per-property observation that ships with the system. On top of that, lightweight MVVM is the default that scales: one ViewModel per screen that has loading or logic, none for trivial views, and never a view talking to the network. TCA is Redux with effects and exhaustive tests built in, and its learning curve is worth it only when team, domain and testing requirements justify it. The two decisions that can't wait are dependency injection (swift-dependencies or Factory) and the split into packages, because the cost of adding them later is measured in weeks.

To get started: create the domain, networking and one feature package on day one; write the first `@Observable` store and the first ViewModel with its dependency injected; and don't touch TCA until you have a screen whose complexity makes you miss a reducer. The [next post](/blog/swiftui-navigation-without-file-based-routing) covers what this one left out on purpose: where navigation state lives when there are no folders defining the routes.
