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.
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:
- app-wide state
- feature state
- view-local transient state
- 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:
SessionModelowns auth/session lifecycleInboxModelowns message loading and filter selectionComposerDraftowns an in-progress compose flowProfileEditorModelowns editing and save actions for that feature only
That boundary gives you useful properties immediately:
- fewer accidental dependencies
- smaller update surfaces
- easier tests
- 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:
isLoadinghasLoadeddidFailisRefreshingerrorMessage
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:
- set
.loading - await result
- set
.loadedor.failed - 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:
allItemsfilteredItemsisEmptycanRetryheaderTitle
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 == trueandreceipt != nilpaymentError != nilandreceipt != 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:
- one layer owns a given navigation decision
- presentation state should map to product concepts, not random booleans
- pending routes should survive async prerequisites intentionally
For example, this is often too loose:
showPaywall = trueselectedArticleID = "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:
- small app-wide models only for truly shared long-lived state
- one feature model per meaningful feature flow
- local
@Statefor view-only transient concerns - derived state computed from minimal stored truth
- enums for important async and navigation states
- child views rendering snapshots or bindings instead of whole models
- 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.