State ownership in SwiftUI: keeping views simple and data flow predictable
A SwiftUI screen looks charming while it is small. A few @State properties, a list, a button. Then the screen grows, state spreads across three places, a change redraws the wrong view, and nobody can say who actually owns the data. Most of these problems do not come from SwiftUI itself but from unclear state ownership.
In this post I focus on a single question: who does this state belong to? The answer decides which property wrapper you use, where the state lives, and which views get updated.
The single source of truth
In SwiftUI a view is a function of state. State changes, the view is recomputed. This model works well only when every value has exactly one owner. If you keep the same information in two places, keeping them in sync becomes your job, and that is where bugs begin.
A practical rule: keep a value in the lowest common parent view or model that needs to change it. Child views either receive the value read-only or receive permission to change it through a Binding. Do not copy it, do not mirror it.
@State is for local, transient state
@State fits simple values a view owns itself: the contents of a text field, whether a sheet is open, the selected tab. These values are tied to that view's lifecycle, and other screens do not need to know about them.
struct NoteEditor: View {
@State private var draft = ""
@State private var isConfirmingDiscard = false
var body: some View {
TextEditor(text: $draft)
.toolbar {
Button("Discard") { isConfirmingDiscard = true }
}
}
}
Marking @State values private is a good habit. If a value must be changed from outside, it is not really this view's state; it should move up to the parent.
Delegating authority explicitly with @Binding
If a child view needs to change a value, use @Binding. A binding is not a copy but a reference back to the owner. The data still lives in one place, and the child only gets access rights.
struct FavoriteButton: View {
@Binding var isFavorite: Bool
var body: some View {
Button {
isFavorite.toggle()
} label: {
Image(systemName: isFavorite ? "star.fill" : "star")
}
}
}
If a child only needs to read, pass a plain value instead of a binding. Less authority means fewer surprises. A view that works with a plain value is also much easier to set up in previews and tests.
An observable model for shared state
State shared across several screens, or state that carries business logic, needs a model class. In modern SwiftUI you can use the @Observable macro for this. A key benefit is that a view binds only to the properties it actually reads in its body, so when another property of the model changes, unrelated views are not recomputed.
@Observable
final class CartModel {
var items: [CartItem] = []
var couponCode = ""
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
func add(_ product: Product) {
// business rules live here, not in the view
}
}
Here is a second principle: keep business rules out of the view. Computing totals, validating coupons and making network calls belong in the model. The view only displays state and forwards user intent to the model. This separation also makes testing easier, because you can test the model without any UI.
Store the model with @State in the view that creates it and pass it down as a parameter. If you have older ObservableObject-based code, keep the distinction between @StateObject and @ObservedObject: the owner that creates the object should use @StateObject, and a view that merely uses it should use @ObservedObject. Picking the wrong owner can cause the object to be reset every time the view is recreated.
Environment objects: use them carefully
Carrying a model deep into the view tree with @Environment is convenient, but it makes the dependency invisible. A view does not declare in its signature which model it needs, and if the model is missing from the environment, the app crashes at runtime.
So reserve the environment for things that are truly app-wide: session information, theme, locale, settings. A model specific to one screen should be passed explicitly as a parameter. The rule: if a dependency is a primary input of the view, show it in the signature; if it is ambient context, let it live in the environment.
Measure redraws, do not guess
Putting state in the right place is also about not doing needless work. If a large view body reads everything in one spot, a small change forces the whole screen to be recomputed. The fix is usually to split the view into sensible pieces, each receiving only the data it needs.
Measure the places you suspect with the SwiftUI tools in Instruments. Debugging helpers such as let _ = Self._printChanges() help you see why a view was recomputed. Measure first, then split; breaking every view apart by reflex can hurt readability too.
A checklist
When writing a new screen, ask these questions in order:
- Does this value belong only to this view? Then
@Stateandprivate. - Will a child view change it? Then
@Binding; if it only reads, a plain value. - Is it shared by several screens or does it hold business rules? Then it goes in a model, with
@Observable. - Is the dependency really app-wide? Then the environment; otherwise a parameter.
- Is the same information stored in two places? Remove one.
Closing
Most complexity in SwiftUI comes from state without an owner. When you assign every value a single owner, which wrapper to choose, where the code should live and what gets redrawn largely become clear on their own. Think about ownership first and pick the wrapper second; your views stay simple and your data flow stays predictable.