Skip to content
All essays

Mobile architecture

SwiftUI state management best practices, from production

Most SwiftUI state bugs are not wrapper bugs. They are ownership bugs: a value with two owners, a binding nobody needed, a store everything reads. The wrappers, the Observation framework and the environment, explained by who owns what.

12 min read

Ask an iOS engineer about SwiftUI state and you usually get a table: @State for local values, @Binding for passing them down, an observable model for the rest. The table is correct. It is also where most state bugs start, because it answers which wrapper to use before anyone has asked who owns the value.

Ask this question across 500+ technical interviews from the hiring side and a pattern shows up. The candidates who stand out on SwiftUI state rarely recite the table. They ask where the truth lives first, and the wrapper falls out of that answer. The wrapper is the last decision, not the first.

This guide is about that order of decisions. If you want the wider argument about SwiftUI architecture, read architecture is state ownership. If you are choosing between patterns, read TCA vs MVVM in SwiftUI. Here we stay at the level of one value and its owner.

  • The four questions to answer before you choose any wrapper.
  • @State, @Binding, @Observable, @Bindable and @Environment, each by what it says about ownership.
  • Derived state, and the bugs that come from a second source of truth.
  • What interviewers probe, and what a senior answer sounds like.

Four questions before any wrapper

SwiftUI Under Load opens its chapter on state ownership with four questions. What is the source of truth for this value? How long does it live? Who is allowed to change it? Does it need to persist? Answer them first, and the storage follows. Choose the storage first, and you find out later that it does not match the value's real lifetime or authority.

Lifetime is the question people skip. A text field draft lives as long as the screen. A selected account lives as long as the session. A navigation path may need to survive a relaunch. Put a session-length value in a screen and it disappears when the screen does. Put a screen-length value in the app store and every other feature can now see and break it.

Authority is the question that catches the subtle bugs. If only the view writes a value, the view owns it. If a deep link, a switcher and a screen can all change it, then no single screen owns it, whatever the code says today.

Write the four answers next to the declaration as a short comment. The next engineer who adds a reader should not have to read the whole screen to learn that the value is session-scoped and changed by deep links.

@State: the view owns it

@State says three things at once: this view creates the value, this view keeps it across re-renders, and this view is the one that writes it. Focus, an expanded section, a selection highlight and a sheet's presence usually belong here. The test is simple: does anything outside this view read the value? If not, it is local, and moving it up is pure cost.

Two details matter in production. First, @State is tied to the view's identity, not to the struct instance. When identity changes, through a different id or a different branch of an if, the state is thrown away and starts again. A screen that resets its scroll position or its draft is often an identity problem, not a state problem.

Second, the initial value expression runs more often than it looks. The iOS Interview Blueprint calls this out in its state chapter: @State private var model = Model() evaluates Model() on every initialisation of the view, and SwiftUI keeps only the first result. A cheap model hides this. A model whose init opens a database or starts a request does that work on every parent update and throws it away. Keep init cheap and start the work in .task.

@Binding: borrowed write access

A @Binding is not a copy of a value. It is a get and set pair that points back at someone else's storage. Giving a child a binding gives it the right to change the parent's state.

Swift
struct QuantityRow: View {
    @State private var quantity = 1          // the owner
    var body: some View {
        QuantityStepper(quantity: $quantity) // lends write access
    }
}

struct QuantityStepper: View {
    @Binding var quantity: Int               // borrows, may write
    var body: some View {
        Stepper("Quantity \(quantity)", value: $quantity, in: 1...9)
    }
}

The rule both books state in their own words: a child gets a binding only if it writes. A child that only shows a value should receive a plain let. A binding the child never writes through tells every future reader something false about who may change that state, and in review it hides the real list of writers.

This sounds like style. It is not. When a bug report says "the quantity changed and nobody tapped anything", the list of bindings is the list of suspects. Keep it short and true.

@Observable and ObservableObject: what changed

The Observation framework, available from iOS 17, changed two things. The first is how dependencies are tracked. With ObservableObject, any @Published change notified every view observing the object. With @Observable, SwiftUI records the properties a view reads while its body runs, and only those properties invalidate that view.

The second is where observation is declared. It moved from the property wrapper to the type. A view no longer says how it observes a model, only that it holds one. That collapses the old @StateObject versus @ObservedObject question into one: do I own this instance's lifetime? If yes, hold it in @State. If it was handed to you, hold it in a plain property. Use @Bindable only when you need bindings out of the model, for a text field or a toggle.

Swift
@MainActor @Observable
final class ProfileEditor {
    var name = ""
    private(set) var phase: Phase = .editing
    enum Phase { case editing, saving, failed(String), saved }

    var canSave: Bool { !name.isEmpty && phase == .editing } // derived, never stored

    func save() async {
        phase = .saving
        try? await Task.sleep(for: .milliseconds(300))
        phase = .saved
    }
}
extension ProfileEditor.Phase: Equatable {}

struct ProfileScreen: View {
    @State private var editor = ProfileEditor() // this view owns the lifetime
    var body: some View { ProfileForm(editor: editor) }
}

struct ProfileForm: View {
    @Bindable var editor: ProfileEditor         // handed in, needs bindings
    var body: some View {
        TextField("Name", text: $editor.name)
        Button("Save") { Task { await editor.save() } }
            .disabled(!editor.canSave)
    }
}

Notice phase. Three flags, isLoading, hasError and isSaving, allow combinations that cannot happen, loading and failed at once. One enum forbids them by construction. SwiftUI Under Load makes this the core of a feature model: expose product meaning, not a bag of booleans.

Observation is a mechanical improvement, not an architecture. It does not decide ownership, isolation or feature boundaries. A global @Observable store that everything reads is still a global store, only nicer to use. That can hide the problem rather than fix it.

Precise tracking needs a narrow API

Fine-grained tracking only helps when the thing being read is fine-grained. The failure SwiftUI Under Load opens chapter 5 with is a convenience property: a root view reads store.summary, and summary internally reads balances, selection, refresh metadata and live prices. The view now depends on all of them, and every price tick re-evaluates it. The framework tracked exactly what was read. What was read was too broad.

So design the model's surface for its readers. Expose the specific property a view needs. Where a summary is useful, compute it when its inputs change and store it as a value, instead of a computed property that touches everything on each access.

This also changes inherited advice. Under ObservableObject you passed children the narrowest value, because any change invalidated every observer. Under @Observable, passing the whole model to a child is fine on its own, because the child depends on what its body reads, not on the object. The cost moved from what you pass to what you read.

A quieter failure goes the other way: the view that does not update. A plain array of values on an @Observable store works. Put a mutable class that is not itself observable inside one of those values, change a field on it, and nothing writes the tracked property, so nothing redraws. The rule is narrower than "no classes": a change the UI must see has to arrive as a write to a tracked property. A network client, a cache or a logger can stay a reference type, marked @ObservationIgnored and injected through init.

@Environment: for values many read and few change

The environment is the right place for values that many views read and that change rarely: a theme, a locale, a session, a router. With Observation you inject an @Observable instance directly and read it by type.

Swift
@MainActor @Observable
final class Session {
    var selectedAccountID: String?
}

struct RootView: View {
    @State private var session = Session()
    var body: some View {
        AccountHeader().environment(session)
    }
}

struct AccountHeader: View {
    @Environment(Session.self) private var session
    var body: some View {
        Text(session.selectedAccountID ?? "No account")
    }
}

Two warnings. First, the environment is not free global access. It is easy to put a broad, writable service there and reach it from anywhere, which hides both who depends on it and who changes it. Second, frequency is an architectural input. SwiftUI Under Load puts it as breadth times frequency: a value that changes once a session can be broad, a value that changes many times a second must be injected at the narrowest subtree that shows it. A live price feed at the app root is an update storm by construction.

When a reading view would otherwise sit inside static layout, pull the live part into its own small view. The extraction looks trivial and moves the dependency boundary, so the header next to it stops re-evaluating. Then measure with the SwiftUI instrument, because a refactor that did not change what is read did not help.

Derived state: compute it, do not copy it

Storing a value you could compute from the source of truth creates two things that must agree, and eventually they will not. canSave above is computed because it is cheap. A sorted, grouped feed of thousands of rows is not cheap, and recomputing it in body on every evaluation is model work done in the wrong place.

The resolution is to derive once, at an explicit boundary, when the inputs change:

Swift
struct Transaction { let date: Date }

@MainActor @Observable
final class FeedStore {
    private(set) var transactions: [Transaction] = [] {
        didSet { sorted = transactions.sorted { $0.date > $1.date } }
    }
    private(set) var sorted: [Transaction] = [] // derived once, at the write
    func replace(with new: [Transaction]) { transactions = new }
}

sorted is stored, but it is not a second source of truth. It has one writer, the didSet, and nothing else can set it. That is the difference between a cache with a rule and a copy that drifts. If you cannot point at the single line that keeps a derived value in step, it is a copy.

Two sources of truth: the bugs that look random

The most common SwiftUI state bug in code review is a copy made at init time:

Swift
// Broken: the parent's value is copied once, then ignored.
struct TitleEditorBroken: View {
    @State private var title: String
    init(title: String) { _title = State(initialValue: title) }
    var body: some View { TextField("Title", text: $title) }
}

// Fixed: one owner, borrowed write access.
struct TitleEditor: View {
    @Binding var title: String
    var body: some View { TextField("Title", text: $title) }
}

The broken version works in the demo. The first value arrives, the user types, the screen looks right. Then the parent changes the title through another path, a sync or an undo, and the child keeps showing its own copy, because @State ignores later initial values for the same identity. Two owners, and they disagree exactly when the value changes by a path one of them does not observe.

The same shape appears at feature scale. SwiftUI Under Load describes a selected account kept as @State in the feed screen, with the balance header and the search filter each holding their own copy. Change the account from the feed and all three agree. Change it from a deep link and the header shows one account's balance over another account's transactions. The fix was to promote the value to a store all three read, and a test that changes it through the switcher and checks both views.

The opposite mistake is just as real: one app-wide store holding focus, validation, drafts, auth and navigation, because it was easy to reach. Previews need the whole app, an unrelated update refreshes a form, and every test configures global state first. The cure is the same four questions. Local state for what nothing else reads. Shared state for what several features must agree on. A store's boundary should match a truth boundary.

A practical rulebook

The valueOwnerIn code
Focus, expansion, a sheet that only this view showsThe view@State, @FocusState
A value a child editsThe parent@Binding in the child
A value a child only showsThe parentA plain let in the child
A screen's workflow: input, phase, errorsA feature model the screen owns@Observable class held in @State
A value several features must agree onA shared store@Observable, injected or in the environment
Navigation that restores or follows deep linksA routerOne owner with clear authority
A sorted or filtered view of the dataNobody: it is derivedComputed, or stored with one writer
Services: API, analytics, a clockInjected collaborators@ObservationIgnored, passed through init

Keep ObservableObject where you still support systems before iOS 17, and at seams with Combine or UIKit. Migrate feature by feature, and check in the instrument that each converted feature updates less, not just differently.

What interviewers probe

The opening question is usually the wrappers. The follow-ups are where the level shows. These come up again and again:

  • "Your child view resets whenever the parent updates. Where do you look?" Model ownership, a model recreated by the parent, and view identity.
  • "When is a plain let better than a @Binding?" Whenever the child only reads.
  • "Does @State private var model = Model() run Model() once?" No. It runs on every view init, and SwiftUI keeps the first.
  • "Under @Observable, is passing the whole model to a child expensive?" Not by itself. The dependency is what the child's body reads.
  • "Three screens need the selected account. Where does it live?" In one owner they all observe, never in one screen's @State.
  • "Why move state into a model if the view works?" So the logic can be tested without rendering anything.

That last one separates levels. A mid-level answer picks the correct wrapper. A senior answer derives it from ownership. A staff answer says that ownership decides testability: state in a view can only be exercised through the view, while the same state in a model is a plain object a unit test can drive. The wrapper follows from that, not the other way round.

For the failures these rules prevent at scale, read three production SwiftUI failures.

Questions engineers ask about SwiftUI state

What are the SwiftUI state management best practices in one paragraph?

Decide the owner before the wrapper. Ask where the truth lives, how long it lives, who may change it and whether it persists. Keep view-only state in @State, hand a child a @Binding only if it writes, put feature state in an @Observable model the screen owns, use the environment for values many views read and that change rarely, and compute derived values instead of storing a second copy.

Should I still use ObservableObject?

For new code that targets iOS 17 or later, @Observable is the better default, because views depend on the properties they read rather than on the whole object. Keep ObservableObject where you still support older systems or at a boundary with Combine and UIKit code, and migrate feature by feature, measuring that each migration narrowed the updates.

Is @StateObject still needed with @Observable?

No. With an @Observable class, a view that creates and owns the model holds it in @State, and a view that was handed the model holds it in a plain property, or in @Bindable if it needs bindings. The old choice between @StateObject and @ObservedObject becomes one question: do I own this instance's lifetime?

What do interviewers ask about SwiftUI state?

They start with the wrappers and move quickly to ownership: why a child view resets, whether a child that only reads should get a binding, whether @State private var model = Model() runs the initialiser once, and where you would put a value that three screens must agree on.

Share this essay