Back to Blog

Modeling app state so SwiftUI updates stay predictable

Predictable SwiftUI starts with boring state boundaries: keep ownership clear, derive local view state instead of sharing giant models, and stop letting one write ripple through half the app.

10 min read

Most SwiftUI bugs people call “random” are not random.

They are state bugs with good camouflage.

A spinner gets stuck. A sheet dismisses itself. A list refresh wipes row expansion. Typing in one field makes another section redraw like it has separation anxiety.

Then somebody says SwiftUI is weird.

SwiftUI is opinionated, yes.

But most of the pain comes from a simpler issue: the app does not have a clear model of who owns what state, how long it lives, and who is allowed to mutate it.

If those boundaries stay fuzzy, updates stop feeling predictable.

1. Start with the only question that matters

Before choosing wrappers, macros, reducers, coordinators, or whatever the framework discourse is selling this week, ask:

what state exists here, and who actually owns it?

That question usually gives you four buckets:

  1. app-wide state
  2. feature state
  3. view-local transient state
  4. derived display state

Teams get into trouble when those buckets collapse into one giant observable object because it feels convenient for the first two screens.

Convenient state models age like milk.

2. Not all state deserves the same lifetime

A practical way to model SwiftUI state is by lifetime first.

App-wide state

This is state with long lifetime and shared meaning:

  • session/authentication
  • current account or workspace
  • feature flags snapshot
  • app-wide navigation or routing inputs
  • subscription or entitlement status

This should be small.

If your “app state” includes screen filters, text-field drafts, selection highlights, and half-completed form steps, it is not app state. It is a junk drawer.

Feature state

This belongs to one feature flow and survives normal view redraws:

  • loaded items
  • pagination status
  • selected filter
  • editing mode
  • async loading/error state

Feature state is where most product logic should live. Not globally. Not spread across five child views.

View-local transient state

This is short-lived UI glue:

  • focused field
  • whether a disclosure group is expanded
  • currently presented confirmation dialog
  • local scroll target
  • temporary draft before commit

This should usually stay close to the view.

Dragging everything upward “for consistency” is how small screens become hostage situations.

Derived display state

This is not owned state at all. It is computed from other state:

  • button enabled/disabled
  • section visibility
  • formatted labels
  • sorted or filtered projections
  • empty-state messaging

Do not store what you can derive cheaply and deterministically. Stored derived state is one of the easiest ways to create drift.

3. The god model is still a bad idea, just with newer syntax

A lot of SwiftUI codebases quietly recreate the same problem in slightly different packaging:

@Observable
final class AppModel {
    var session: Session?
    var selectedTab: Tab = .home
    var inbox: [Message] = []
    var notificationsEnabled = false
    var profileDraft = ProfileDraft()
    var paywall: PaywallState?
    var isSyncing = false
    var searchQuery = ""
}

This looks efficient because everything is reachable from one place.

It is also how one harmless write starts splashing through unrelated UI.

Change searchQuery, and some screen that also reads isSyncing from the same model suddenly reevaluates. Change selectedTab, and now a modal subtree gets rebuilt because it touched some other property on the way down.

The syntax changed. The architecture did not.

If a view needs three values, do not inject the object that knows thirty.

4. Split by ownership, not by folder aesthetics

A better rule than “one model per screen” or “one store per module” is this:

split state where ownership and mutation rules diverge.

For example:

  • SessionModel owns auth/session lifecycle
  • InboxModel owns message loading and filter selection
  • ComposerDraft owns an in-progress compose flow
  • ProfileEditorModel owns editing and save actions for that feature only

That boundary gives you useful properties immediately:

  1. fewer accidental dependencies
  2. smaller update surfaces
  3. easier tests
  4. fewer fake abstractions to pass data around

This is less glamorous than inventing a universal state layer.

It is also more likely to keep working in six months.

5. Keep source of truth singular, even when the UI has many views

One reliable way to make SwiftUI feel unpredictable is duplicating mutable state across layers.

Classic examples:

  • the parent stores selectedFilter
  • the child stores its own selectedFilter
  • the toolbar stores another representation for display
  • a task writes back into one of them when loading finishes

Now everybody is “correct” for about seven seconds.

Then they drift.

A healthier rule:

  • one owner for mutable truth
  • child views receive values or bindings to that truth
  • derived representations stay derived

For example:

struct InboxScreen: View {
    @State private var model = InboxModel()

    var body: some View {
        InboxContent(
            items: model.visibleMessages,
            selectedFilter: model.filter,
            onSelectFilter: { model.filter = $0 }
        )
    }
}

That is not revolutionary.

Good.

Predictable state management is mostly the art of refusing unnecessary copies.

6. Treat async work as state transitions, not side effects with vibes

A surprising amount of SwiftUI flakiness comes from async work mutating state from arbitrary places.

You tap refresh. A task starts. A retry also starts. The view disappears. A late response arrives and writes into state that no longer describes the current screen. Now QA has a “sometimes weird” ticket.

Model async work explicitly.

Instead of vague booleans sprinkled around the view tree:

  • isLoading
  • hasLoaded
  • didFail
  • isRefreshing
  • errorMessage

prefer a state that admits the real transitions.

enum Loadable<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(message: String)
}

Then the feature model owns transitions deliberately:

  1. set .loading
  2. await result
  3. set .loaded or .failed
  4. ignore stale results when a newer request supersedes them

That is much easier to reason about than “flip three booleans and hope they still form a valid combination”.

7. View-local state should stay local until it proves otherwise

Not every state variable deserves promotion.

If a disclosure section is expanded, and nothing outside that view cares, keep it there.

If a text field has a temporary draft that is only committed on save, keep it there.

If a sheet needs a local picker selection before confirming, keep it there.

Developers often hoist this state upward too early because they want a “single source of truth”.

That phrase is correct and still regularly abused.

Single source of truth does not mean centralize every temporary pixel-level decision in the root. It means each mutable concern has one owner.

Sometimes that owner is a tiny leaf view.

That is allowed.

8. Derived state should be cheap, obvious, and mostly not stored

Stored derived state is seductive because it feels explicit.

It also creates tedious invalidation work.

Suppose you store all of these:

  • allItems
  • filteredItems
  • isEmpty
  • canRetry
  • headerTitle

Now every write has homework. Did the filter change? Did the fetch finish? Did auth change? Did the locale change? Did somebody forget to recompute one field?

A better approach is to store the minimum and derive the rest close to use.

@Observable
final class InboxModel {
    var items: [Message] = []
    var filter: MessageFilter = .all
    var loadState: Loadable<Void> = .idle

    var visibleItems: [Message] {
        switch filter {
        case .all:
            items
        case .unread:
            items.filter(\.isUnread)
        }
    }

    var isEmpty: Bool {
        visibleItems.isEmpty
    }
}

This still needs discipline.

If a computed property reads half the world, it becomes a wide dependency surface. But that is still usually easier to control than synchronizing duplicate stored values.

9. Make invalid states hard to represent

One quiet source of SwiftUI chaos is modeling state with loose optional bags.

For example:

struct CheckoutState {
    var selectedPlan: Plan?
    var isPaying = false
    var paymentError: String?
    var receipt: Receipt?
}

Now the model can represent all kinds of nonsense:

  • isPaying == true and receipt != nil
  • paymentError != nil and receipt != nil
  • no selected plan but payment in progress

You can paper over that with UI conditionals. You can also create the mess more honestly and then spend less time cleaning it.

Prefer states that encode the allowed situations:

enum CheckoutState {
    case selectingPlan(selected: Plan?)
    case paying(plan: Plan)
    case failed(plan: Plan, message: String)
    case completed(receipt: Receipt)
}

This is not purity theater.

It is bug prevention.

When invalid states are impossible, SwiftUI usually stops looking moody and starts looking consistent.

10. Pass snapshots down the tree more often than models

A view that renders a row usually does not need access to the entire feature model.

It needs a handful of values.

Pass those values.

struct MessageRow: View {
    let title: String
    let preview: String
    let isUnread: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.headline)
            Text(preview)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .fontWeight(isUnread ? .semibold : .regular)
    }
}

This does a few helpful things:

  • narrows dependencies
  • reduces accidental reads from shared models
  • improves previews and tests
  • makes row identity and diffing easier to reason about

You do not need a crusade against passing models.

You just need to stop doing it by default.

11. Navigation and presentation state need ownership too

Sheets, alerts, destinations, and navigation paths are state.

Teams treat them as plumbing and then wonder why presentation bugs multiply.

A few rules make this calmer:

  1. one layer owns a given navigation decision
  2. presentation state should map to product concepts, not random booleans
  3. pending routes should survive async prerequisites intentionally

For example, this is often too loose:

  • showPaywall = true
  • selectedArticleID = "123"
  • showLogin = false

What does that combination mean exactly? Who wins if two become true together? Who clears them after completion?

A more honest shape is often an enum:

enum ActiveModal: Identifiable {
    case paywall(source: PaywallSource)
    case login(continuation: ProtectedRoute)

    var id: String {
        switch self {
        case .paywall:
            "paywall"
        case .login:
            "login"
        }
    }
}

Again: fewer combinations, fewer surprises.

12. The smell to watch for: state that moves without a clear event

When a UI update feels surprising, ask one blunt question:

what exact event caused this state change?

If the answer is fuzzy, the model is usually fuzzy too.

Healthy feature state tends to have a readable event story:

  • user tapped retry
  • refresh task started
  • response succeeded
  • selection changed
  • save completed
  • route resolved

Unhealthy state models sound like this:

  • the task callback did something
  • the view appeared again
  • the child updated the parent somehow
  • Observation fired
  • SwiftUI re-rendered weirdly

No.

Framework behavior matters, but vague event ownership is usually the larger problem.

13. The baseline I would actually ship

For a normal product app, I would keep the baseline boring:

  1. small app-wide models only for truly shared long-lived state
  2. one feature model per meaningful feature flow
  3. local @State for view-only transient concerns
  4. derived state computed from minimal stored truth
  5. enums for important async and navigation states
  6. child views rendering snapshots or bindings instead of whole models
  7. explicit event methods for mutations instead of random writes everywhere

This is not the only workable architecture.

It is just one that fails less dramatically.

14. The rule worth keeping

If you keep one rule from this post, keep this one:

Model state around ownership and lifetime, not around convenience of access.

Convenience makes the first screen easy. Ownership makes the fiftieth screen survivable.

And when SwiftUI updates start feeling predictable, it is usually not because you discovered a secret wrapper.

It is because the app finally stopped lying about where its state lives.