iOS – State, @Observable and Data Flow

July 26, 20268 min readUpdated 9/18/2026

SwiftUI has four property wrappers for state and they are not interchangeable. Choosing wrongly does not usually produce an error — it produces a screen that does not update, or one that resets itself, which is considerably worse.

This lesson is the decision procedure, then what @Observable actually changed, then the line between state a screen owns and state the app owns.

The four, and when each applies

Start with the question "who owns this?", not "what type is it?".

  • @State — this view owns it, nothing else needs it. A filter selection, whether a sheet is open, what is typed in a field.
  • @Binding — someone else owns it and has lent me write access. A child component that edits a parent's value.
  • @Observable (on a class) — several views share it and it outlives any one of them. The cart, the signed-in user.
  • @Environment — how a view reaches something injected from far above without every layer in between passing it down.

The rule underneath all four: state lives at the lowest level that can see everything that needs it. Moving state upward "just in case" is how a small app acquires a global store and a re-render problem.

@State

@State gives a view a piece of storage that survives its body being re-run. That is necessary because the view struct itself does not survive — SwiftUI throws it away and builds a new one constantly. @State is kept outside, keyed to the view's identity.

    @State private var filter: Filter = .all
    @State private var productBeingBuilt: Product?

Two things that are always true of good @State. It is private — nobody outside the view has any business touching it. And it is small: which filter is showing, which product is being configured. Notice the second one does double duty, because "no product" and "sheet closed" are the same fact, so modelling them as one optional removes a state that could disagree with itself.

Identity, and the reset you did not ask for

@State is tied to a view's identity. Change the identity and the state is discarded and rebuilt. This is the mechanism behind a class of bug that looks like magic in both directions — state mysteriously resetting, or mysteriously persisting between two things that should have been separate.

It is also a tool. In the demo app the pizza builder is presented with .sheet(item:), which builds a fresh view for each distinct product — so opening a different pizza starts from that pizza's defaults with no code at all. The React Native version of the same screen needs a counter bumped on every open and a long comment explaining why, because there the reset has to be arranged by hand.

@Binding

A @Binding is a read-write handle to state owned elsewhere. The parent passes it with $:

                SegmentedPicker(
                    segments: Filter.allCases.map { .init(value: $0, label: $0.title) },
                    selection: $filter
                )

The picker can now write the parent's filter without knowing anything about the parent. This is what makes a component reusable: it declares "I need somewhere to put a selection", and every caller supplies a different one.

Bindings can also be built by hand, which is how you adapt one shape to another — an optional property to a non-optional TextField, or a computed value to a control. That escape hatch exists and is normal; it is written as Binding(get:set:).

@Observable

For state shared across screens you need a reference type, and since iOS 17 the way to make one observable is a macro. The catalogue store is declared @MainActor @Observable final class MenuStore, and its state is one property:

    private(set) var state: ViewState<Catalogue> = .idle

    var catalogue: Catalogue { state.value ?? Catalogue() }

    var isLoading: Bool { state.isLoading }

    private let repository: CatalogRepository
    private var loadTask: Task<Void, Never>?

Three annotations, all load-bearing.

@Observable replaced ObservableObject, and it changed something real. The old protocol required @Published on every property and invalidated every observer when any published property changed. The macro tracks reads at the property level instead: a view that reads only isLoading is not redrawn when catalogue changes. For a small type that is invisible; for a store four screens read, it is the difference between redrawing a badge and redrawing a menu.

@MainActor puts the whole type on the main actor. Everything here drives UI, so the compiler — not a runtime assertion — now enforces that it is only touched from the main thread. This is what prevents the classic "publishing changes from background threads" crash; lesson 12 goes into what it costs.

private(set) means views can read state and cannot assign to it. Every change goes through a method on the store, which is what makes the store's guarantees worth anything. A store whose properties anyone can write is a struct with extra ceremony.

@Bindable

@Observable types have no $ projection by default. When a control needs to write one, @Bindable produces the bindings:

        @Bindable var cart = cart

That line looks like it does nothing and is doing the whole job — it is the replacement for @ObservedObject's $ syntax. In the demo app it lets a picker write cart.orderType directly, while the store's setter still routes the change through its reducer. The view gets two-way syntax; the store keeps its invariant.

Changes go through methods

private(set) forces every mutation into a method, and that turns out to be where the interesting code lives. The toast centre is the smallest complete example — showing a message also schedules its removal, and dismissing one by hand has to cancel that:

    func show(_ message: String, style: Toast.Style = .success) {
        let toast = Toast(message: message, style: style)
        toasts.append(toast)

        dismissalTasks[toast.id] = Task { [weak self] in
            try? await Task.sleep(for: self?.visibleDuration ?? .seconds(3))
            guard !Task.isCancelled else { return }
            self?.dismiss(toast.id)
        }
    }

    func dismiss(_ id: Toast.ID) {
        dismissalTasks[id]?.cancel()
        dismissalTasks[id] = nil
        toasts.removeAll { $0.id == id }
    }

If toasts were writable from outside, a caller could append a message without the timer and it would sit on screen forever — and the type would have no way to stop them. That is the whole argument for the access modifier: it is not about hiding, it is about the object being able to keep a promise.

@Environment

Passing a store down six levels of view is miserable, and every intermediate view that only forwards it gains a dependency it does not use. The environment solves the same problem React's context does:

                .environment(environment)
                .environment(environment.auth)
                .environment(environment.menu)
                .environment(environment.cart)
                .environment(environment.toasts)

Injected once at the root, read anywhere below:

    @Environment(MenuStore.self) private var menu
    @Environment(AuthStore.self) private var auth
    @Environment(AppRouter.self) private var router

For @Observable types this needs no EnvironmentKey — the type is the key. There is one sharp edge: reading a type nobody injected is a crash, not a compile error. In practice the crash happens the first time you render that screen, which is soon enough that it is rarely a real problem, but it is worth knowing that the guarantee is not static.

The environment also carries system values — \.scenePhase, \.colorScheme, \.isEnabled, \.dismiss — read the same way with a key path.

Screen state or app state?

This is the decision that shapes a codebase, and it is easy to get wrong in the generous direction.

The demo app has exactly three shared stores — authentication, the catalogue, the cart — plus a toast centre. Everything else belongs to one screen. The test is not "might something else want this one day"; it is "does something else need it now". Three screens read the cart, so the cart is shared. Nobody outside the profile screen needs the list of saved addresses, so that state lives in a view model beside it.

The cost of getting it wrong in the sharing direction is not just re-renders. State in a store lives for the whole session, so a screen that puts its scratch state there finds it still populated the next time it is opened — and two instances of the same screen share one selection.

The view model, and when it earns its place

A screen with rules gets an @Observable object of its own:

@MainActor
@Observable
final class PizzaBuilderViewModel {
    let product: Product

    var selectedSize: SizeName
    var selectedCrustID: UUIDString?
    var quantity: Int = 1

    private(set) var selectedToppingIDs: Set<UUIDString> = []

Four interacting selections, a price derived from them, and a topping list to group and toggle. That is enough rules to be worth extracting and worth testing without rendering anything.

The home screen, by contrast, has no view model at all. It reads two stores and lays them out. Adding a HomeViewModel that forwarded those reads would be ceremony — and a codebase where every screen has one teaches the wrong lesson, which is that the pattern is a rule rather than a response to complexity.

Holding a view model in @State

A reference type in @State looks wrong and is right. @State gives it a lifetime tied to the view's identity, so it is constructed once and kept across every redraw. A plain let would rebuild it — and reset every selection — on each one, which for a sheet that redraws on every topping tap means it would never survive the first.

Derived values, not stored ones

Notice that the builder stores selections and computes the price, rather than storing a price and updating it from four setters. Four places that can forget is four places that will, and the failure mode — a total briefly wrong after a tap — is exactly the kind of bug that survives review. A computed property cannot be stale.

The same reasoning runs through the stores: catalogue and isLoading above are both computed from state. One source of truth, and no way for two properties to disagree about what is happening.

What replaced ObservableObject, in one table

If you are reading older code or older tutorials, the mapping is worth having:

  • ObservableObject + @Published becomes @Observable, with no per-property annotation.
  • @ObservedObject becomes a plain property, or @Bindable when you need $.
  • @StateObject becomes @State.
  • @EnvironmentObject becomes @Environment(Type.self).

The old spellings still work, and mixing them is fine while a codebase migrates. What you lose by staying on the old ones is the per-property tracking, which is the entire reason the new macro exists.

Next

State that several screens share raises the obvious question of how you get between those screens. The next lesson is navigation: typed routes as values, a stack per tab, and pushing a destination after a payment without the back gesture returning to it.