@State is storage a view owns: SwiftUI keeps the value outside the view struct and re-renders the view when it changes. @Binding is a read-write reference to state owned by someone else; the child can change the value but does not store it.
Ownership: @State is the source of truth, @Binding is a borrowed handle. One piece of state has exactly one owner.
Declaration: @State is private and usually has an initial value (or gets one in init); @Binding has none, the parent passes $value in.
Typical use: @State for local UI state: a toggle, the text in a field, whether a sheet is shown. @Binding for a reusable control that edits its parent's value, exactly as Toggle(isOn:) and TextField(text:) do.
Value types: @State is meant for values. A reference model needs @Observable (see the questions below).
iOSMiddle
SwiftUI State Management
@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject, Observation
Questions closed
0 / 24
- 0Review
- 8Asked often
Questions
count is the current value (the wrapper's wrappedValue), $count is a Binding<Int> to it (the projectedValue), and _count is the State<Int> wrapper itself, which you only touch in init.
count: reading it in body makes the view depend on it; assigning it schedules a re-render.
$count: the $ prefix asks the wrapper for its projectedValue. For @State and @Binding that is a Binding, which is why Stepper(value: $count) can write back.
_count: the stored property the compiler generates for the wrapper. State(initialValue:) sets the starting value, and it is used only the first time this view appears.
$ is not SwiftUI magic: any property wrapper with a projectedValue gets it. @FocusState projects a FocusState<Value>.Binding and @Published a Combine publisher.
@ObservedObject receives an object explicitly through the initializer, while @EnvironmentObject and @Environment read something injected higher up the tree, so the views in between do not have to pass it along (prop drilling).
@ObservedObject: an ObservableObject the parent passes in. The dependency is visible in the view's init.
@EnvironmentObject: an ObservableObject injected with .environmentObject(obj) and looked up by type. Forget the injection and the app crashes at runtime with "No ObservableObject of type … found".
@Environment reads values by key path (\.dismiss, \.colorScheme, your own keys declared with @Entry) and, since iOS 17, @Observable objects by type: .environment(session) plus @Environment(AppSession.self). This replaces @EnvironmentObject in new code.
When to use the environment: for cross-cutting things many screens read: the signed-in session, theme, a router, feature flags. If only one child needs a value, pass it through init: the dependency stays visible, and previews and tests need no setup.
It reads @Environment(\.dismiss) and calls it. The environment knows whether the view was presented as a sheet or pushed onto a navigation stack, so the child needs no binding back to whoever presented it.
\.dismiss: a DismissAction that replaced the old presentationMode. From a pushed screen it pops; from a sheet it closes the sheet.
\.scenePhase: .active, .inactive or .background; the place to save a draft or pause a timer.
\.horizontalSizeClass: adapt the layout for iPad and Split View instead of checking the device model.
\.colorScheme, \.dynamicTypeSize, \.locale: appearance and formatting. Overriding them with .environment(...) in a preview is how you check dark mode, large text or another language.
Reading an environment value makes the view depend on it, so the view re-renders when that value changes.
@FocusState (together with .onSubmit) is missing. @FocusState mirrors keyboard focus: bind fields to it with .focused(...), assign a field to move focus there and nil to hide the keyboard.
Setup: enum Field { case email, password }, @FocusState private var focused: Field?, and .focused($focused, equals: .email) on each control. A single field can use the Bool form, .focused($isFocused).
Next: .submitLabel(.next) plus .onSubmit { focused = .password }.
Done: set focused = nil, from .onSubmit or from a button in ToolbarItemGroup(placement: .keyboard).
It is two-way: the system writes to it when the user taps another field or swipes the keyboard away, so react with .onChange(of: focused) instead of assuming you alone set it.
Initial focus: set it in .onAppear; inside a sheet that is still animating the write can be lost, so use .task.
State(initialValue:) is used only the first time a view with this identity appears. After that SwiftUI keeps its own stored value and ignores every new initial value the parent passes, so count stays at 0.
If the parent owns the number: the child should not have @State at all. Take @Binding var count: Int, or a plain let count: Int if it only displays it.
If the child owns it but must restart when the input changes: give it a new identity: Counter(start: preset.start).id(preset.id). A new id throws the old state away, so the initializer's value is used again.
If other state in the child must survive: store let start: Int and re-seed only this value with .onChange(of: start) { _, new in count = new }.
Seeding @State from a parameter is one of the most common sources of "the form shows stale data" bugs. When you see _x = State(initialValue:), ask who really owns the value.
@StateObject means the view owns the object: SwiftUI creates it once and keeps it alive for the view's lifetime. @ObservedObject only watches an object someone else owns, so an object created inline with it is recreated whenever the parent re-renders.
Symptom of the wrong choice: @ObservedObject var vm = CartViewModel() resets silently: a form clears while the user types, a loaded list vanishes, a counter jumps back to zero when an unrelated parent redraws.
The rule: the view that creates the model uses @StateObject; every view that receives it uses @ObservedObject or @EnvironmentObject.
The opposite mistake: @StateObject for an object passed in from outside keeps the first instance, and later instances from the parent are ignored.
Today: with @Observable (iOS 17+), the owner holds the model in @State and children take a plain let. You still need both wrappers for existing code and for targets below iOS 17.
@Published wraps a property and emits on the object's objectWillChange publisher just before the value changes; views observing the object (@StateObject, @ObservedObject, @EnvironmentObject) then re-run body.
One signal for the whole object: objectWillChange fires once per mutation of any @Published property, so every observer re-renders, even one that read a different property.
**$title is a publisher**: the projected value is a Combine Published.Publisher, so you can sink or debounce it, the classic case being a search field.
Manual announcement: state that is not @Published notifies nobody; call objectWillChange.send() before changing it.
Main thread: change @Published values on the main thread; a background write triggers the purple runtime warning.
Today: @Observable removes both the protocol and the wrapper, and narrows updates to the properties a view read.
Cart is a class, so @Published var cart holds a reference that never changes. Appending to cart.items fires Cart's own objectWillChange, which no view observes, so ShopModel stays silent and the toolbar keeps the old count.
Why it happens: @Published emits only when its property is assigned. For a class that means replacing the whole cart reference; SwiftUI subscribes to ShopModel.objectWillChange, not to objects stored inside it.
The fix: make Cart a struct. Mutating model.cart.items then writes a new value through the cart setter, @Published fires and body runs again.
**If Cart must stay a shared class**: hand it to a subview that observes it with @ObservedObject, or forward its changes: cart.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }, stored in a Set<AnyCancellable>.
**With @Observable**: nesting works: when both classes are @Observable, reading model.cart.items in body records a dependency on that Cart's items.
Catch it next time: a unit test that subscribes to model.objectWillChange, appends an item and expects one emission; with the nested class it gets none.
@Observable makes every stored property of a class observable without @Published, and SwiftUI tracks which properties each view actually read, so a view re-renders only when those properties change instead of on any change to the object.
Less ceremony: no protocol conformance, no @Published, no objectWillChange. A property opts out with @ObservationIgnored.
Finer updates: with ObservableObject, any @Published write fires objectWillChange and every observing view re-renders. With @Observable, a view that reads only model.title ignores changes to model.posts.
Simpler wrappers: the owner uses @State, children take a plain let, @Environment(Model.self) replaces @EnvironmentObject, and @Bindable provides bindings.
Wider reach: tracking follows whatever body reads, so an optional model or an array of models works, which @ObservedObject could not observe.
Limits: classes only, iOS 17+. It is not tied to SwiftUI: withObservationTracking and, from Swift 6.2, the Observations (iOS 26+) async sequence observe the same model from plain code, and UIKit in iOS 26 tracks reads in layoutSubviews the same way.
@Bindable turns an @Observable object into a source of bindings: $profile.name becomes a Binding<String> for a TextField. You need it only in a view that did not create the model but has to hand two-way bindings to controls.
Not for observing: a child that only reads the model takes a plain let, and updates still arrive.
Not in the owner: @State private var profile = Profile() already projects $profile.name.
For a model passed in: declare @Bindable var profile: Profile.
For a model from the environment: @Environment(Profile.self) gives no $, so declare a local @Bindable var profile = profile inside body.
**Only for @Observable classes**: a value type needs @Binding; for an ObservableObject, the $ of @ObservedObject does the same job.
@State re-renders the view when the value it stores is replaced, and draft.title = "Hello" changes the object behind the reference without replacing the reference, so SwiftUI sees no change.
With a struct the same line works: mutating a struct property writes a whole new value back through @State's setter.
The fix: mark the class @Observable. The redraw then comes from Observation, which recorded that body read draft.title; @State only keeps the instance alive across re-renders.
Below iOS 17: ObservableObject with @Published var title, owned by @StateObject.
The same trap in disguise: a struct that holds a plain class inside it, and a model stored as let model = Model() on the view, which is recreated with every new view struct.
Rule of thumb: @State answers "who owns this", @Observable answers "who is told when it changes". A reference model needs both.
The view that creates the state owns it: @State for a value or an @Observable model. Everyone else gets a plain let to read, @Binding (or @Bindable for a model) to write, or the environment for a whole subtree.
Value types (Int, String, structs)
Owned by this view: @State private var.
Owned by a parent, and the child changes it: @Binding.
The child only displays it: a plain let.
Reference models (@Observable, iOS 17+)
This view creates it: @State private var model = Model().
Passed in and only read: let model: Model.
Passed in and the view needs $model.field: @Bindable var model: Model.
Shared by a whole subtree: .environment(model) plus @Environment(Model.self).
Legacy ObservableObject
Creates it (@StateObject); receives it (@ObservedObject); reads it from the environment (@EnvironmentObject).
Special purpose
@AppStorage for persisted settings, @SceneStorage for per-window UI restoration, @FocusState for keyboard focus, @Environment(\.key) for system values.
The two mistakes interviewers listen for: a second owner for one value (a @State copy of something the parent already owns) and a model created by a view that does not own it (@ObservedObject var vm = VM()).
A Binding is just a getter plus a setter, so you can build one with Binding(get:set:) whenever the value lives where a $ cannot reach: behind a type conversion or inside an optional.
Use the built-in projections first: $value on @State and @Binding, $model.field through @Bindable, and List($items) { $item in ... } for one binding per element.
Conversion: a Slider needs a Double but the model stores an Int: Binding(get: { Double(user.age) }, set: { user.age = Int($0) }).
Optionals: Binding($nickname) turns Binding<String?> into Binding<String>?, which you unwrap with if let to give a child a non-optional binding.
Read-only: .constant(true) for previews and locked controls.
Keep both closures light: SwiftUI calls the getter on every body evaluation, so no work or side effects there; the setter runs on each user edit, so keep it to assigning the value, with no side effect such as a request per keystroke.
Do not copy a bound value into a local @State "to edit it": the copy stops following the source.
The detail screen got a copy. Order is a struct, so passing it as let order or seeding a local @State from it gives the child its own value, and every edit lands on that copy.
Values: one owner, bindings down: List($orders) { $order in ... } gives each row a binding into the array, and the detail screen takes @Binding var order: Order. Edits are written straight into the list's array.
Deletion while the detail is open: element bindings point into the array by position, and removing the element underneath can crash with "Index out of range". If items can disappear elsewhere, build a Binding(get:set:) that looks the order up by id.
References: one shared object: make Order an @Observable class kept by a store. Both screens hold the same instance, and the detail uses @Bindable var order for $order.note.
Choosing: structs give value semantics and safe hand-off between actors but need a single owner plus bindings; a class is simpler when one entity is on screen in several places at once.
What is never an option is both: two sources of truth for one entity is exactly the bug in the question.
SwiftUI re-runs body when something the view read has changed: its state, a binding, an environment value, an observed property, or the inputs its parent passed. It resets @State when the view's identity changes, because state belongs to identity.
Dependencies: whatever body read last time. A change invalidates only the views that read it; the rest of the tree is left alone.
Structural identity: a view's position in its parent's body. The two branches of an if/else are two different views, so switching branches destroys one and creates the other with fresh state.
Explicit identity: .id(value) and the ids in a ForEach. A new id means a new view: @State resets, .task restarts, onAppear fires again.
Lifetime: @State, @StateObject and running .task work live exactly as long as the identity does.
In practice: to keep state, prefer a conditional modifier (.opacity(isHidden ? 0 : 1)) over an if; use .id(...) on purpose when you want a reset; give ForEach stable ids, never array indices for a list that changes.
The flag lives in the row's @State, and a List or LazyVStack is free to destroy rows that are far off screen. SwiftUI does not promise to keep their state, so the row comes back built fresh with the default value.
What is lost: discarding a row drops its @State and cancels its .task. On the way back the row starts from defaults and .task runs again, which is also why per-row fetches can repeat during one scroll.
The fix: keep state that has to survive above the lazy container, keyed by id: a Set<Item.ID> of expanded rows in the parent, handed down as a Binding, or a flag on an @Observable model that a store keeps alive.
Per-row loading: go through a cache, so a second appearance is a lookup, not a request.
When row state is fine: a press highlight or a drag offset: losing it off screen is invisible. The test is whether the user would notice the reset.
A ForEach in a plain VStack keeps every row alive, which is why the same code seems fine with ten items and breaks with a thousand in a List.
Never in body, which SwiftUI may run at any time and any number of times. Side effects go in action closures and event modifiers (.task, .onChange, .onAppear), and a .task is cancelled automatically when its view disappears.
.task { }: starts when the view appears and is cancelled when it disappears. .task(id: value) cancels and restarts whenever value changes, which makes it the tool for reloading on a new id or debouncing a search field.
.onChange(of:): reacts to one value changing; the closure takes (oldValue, newValue), and initial: true also runs it on first appearance.
.onAppear: synchronous and fires again on every return to the screen. A Task { } started there is not cancelled with the view.
.refreshable: pull-to-refresh; the spinner stays until the async closure returns.
Cancellation is cooperative: URLSession, Task.sleep and task groups stop on cancel; your own loops must check Task.isCancelled.
**Writing state inside body**: produces the "Modifying state during view update" warning and can loop forever.
Keep the logic itself in async methods on the model; the view only triggers them, so the logic is testable without a view.
@AppStorage is a property backed by UserDefaults that re-renders the view when the key changes; @SceneStorage is per-window UI state that the system saves and restores when the app is relaunched.
@AppStorage
Every view reading the same key re-renders when any of them writes it.
Supported types are the UserDefaults ones (Bool, Int, Double, String, URL, Data) plus any RawRepresentable backed by String or Int, which covers settings enums.
Pass store: with an App Group suite to share a value with a widget or an extension.
@SceneStorage
Scoped to one scene: on iPad every window keeps its own copy. Use it for restoring UI (the selected tab, an unsent draft), never as the source of truth.
Where they stop
Secrets: UserDefaults is an unencrypted plist that ends up in backups. Tokens and passwords belong in the Keychain.
Model data: anything you query, sort or migrate belongs in SwiftData, Core Data or a file, not a Data blob under a defaults key.
Testable code: @AppStorage is global state reached from inside a view. In a larger app, put settings behind an @Observable object that takes a UserDefaults instance and inject that instead.
The macro turns each stored property into a computed one that reports reads and writes to an ObservationRegistrar, and SwiftUI runs body inside observation tracking, so a view depends on exactly the properties it touched during that run.
The mechanism
@Observable moves the storage into a hidden @ObservationIgnored property; the getter calls registrar.access(self, keyPath:) and the setter wraps the write in registrar.withMutation(of:keyPath:).
SwiftUI evaluates body inside withObservationTracking(_:onChange:), and every access during that evaluation is recorded against the view.
A later write to any recorded key path fires the change handler, and SwiftUI schedules an update. The handler fires once; SwiftUI re-arms it on the next body run. Your own withObservationTracking call has to re-register too, which is the loop Swift 6.2's Observations (iOS 26+) sequence writes for you.
What it misses
**A property body never read**: reading it in onAppear, inside a Task, or after a guard that returned early creates no dependency.
**@ObservationIgnored properties: and computed properties that read only ignored storage. Computed values are tracked through the stored properties they read, not on their own.
A nested plain class**: mutating an object that is not itself @Observable changes nothing the registrar sees.
Timing: the change handler runs before the new value is stored, so reading the property inside it returns the old value.
Mental model: observation is a subscription per property read, not per object. If the view did not read it in body, it will not hear about it.
Every write a view can observe has to happen on the main actor. SwiftUI views, @State and the rendering that reads your model are @MainActor, so a write from another thread is a data race, not merely a slow update.
What each generation does about it
**ObservableObject**: catches it at runtime: a @Published write off the main thread raises the purple "Publishing changes from background threads is not allowed" warning.
**@Observable: does not warn. An off-main write shows up as a stale row, a crash deep inside SwiftUI, or nothing until a slower device.
Swift 6 language mode**: diagnoses it at compile time. Capturing a non-Sendable model in a @Sendable closure or Task.detached is flagged: an error for Task.detached and your own @Sendable closures, only a warning for older completion-handler APIs such as URLSession.dataTask. New Xcode 26 app targets also default to MainActor isolation, so app code is main-actor unless marked otherwise.
How to write it
Mark the model @MainActor. Its properties can then be written only on the main actor, and the views read them without a hop.
Call slow I/O with await from a main-actor method. URLSession.data(from:) suspends without blocking the main thread; Task.detached is not needed.
Move CPU-heavy work such as decoding a large payload into a @concurrent function (Swift 6.2) and await it; the result is assigned back on the main actor. With Swift 6.2's approachable-concurrency settings, a plain nonisolated async function runs on the caller's actor, so it no longer leaves the main thread by itself.
Bridge callback APIs with withCheckedContinuation, or hop with Task { @MainActor in ... } inside the completion.
The trap that remains: a Task { } started from a view inherits main-actor isolation, so long synchronous work inside it still freezes the UI. Isolation decides where code runs, not how long it takes.
@State evaluates its initial value every time the view struct is created, and SwiftUI recreates SupportChatScreen on every parent redraw. Each time a new ChatModel opens a socket in init; @State keeps the first model and drops the rest, but they have already connected.
Why it happens: a view's init is not a lifecycle event. When the parent re-runs body, it calls SupportChatScreen() again, and the default value ChatModel() is built again. @State ignores it after the first appearance, but the side effect has already run.
Why it appears after a migration: @StateObject takes an @autoclosure and builds the object once per view identity. @State takes a plain value, so moving an ObservableObject to @Observable + @State brings this bug along.
The fix: keep init cheap and free of side effects, and start the work in .task: it starts when the view appears and is cancelled when it disappears (not on every parent redraw), and cancellation ends the for await loop; close the socket when the task is cancelled (defer { socket.disconnect() }). The throwaway models that are still allocated cost only memory.
When even the allocation is expensive: store @State private var model: ChatModel? and create it inside .task, or let a parent that lives longer own the model.
Catch it next time: log or count in the model's init and toggle unrelated parent state; in review, treat I/O, timers or subscriptions in the init of a model a view creates as a red flag.
First prove which views re-render and why, then fix the cause. The tools are Self._printChanges() inside the suspect body and the SwiftUI instrument in Instruments, run on a release build on a real device.
Measuring
let _ = Self._printChanges() at the top of body prints what invalidated it: @self when the view's inputs changed, @identity for a new identity, or the name of the property that changed. Debug only.
The SwiftUI instrument shows how often each view's body runs and how long it takes, and in Xcode 26 also which state change caused each update. It tells you whether one view runs a hundred times or one body takes ten milliseconds.
The usual causes
A view takes the whole model but needs one field: with ObservableObject, any @Published write re-renders every observer. @Observable narrows that to the properties actually read, the strongest argument for migrating.
**Expensive work in body**: sorting, grouping, creating a DateFormatter, repeated on every evaluation.
Lost identity: AnyView, an .id(...) built from something unstable, or if branches that swap views force teardown and rebuild instead of a cheap update.
State too high: a text field's @State in the screen root re-runs the whole screen on every keystroke.
The fixes
Split large views so invalidation stays local, and move @State down to the smallest view that uses it.
Pass rows the values they display, not the whole model.
Compute derived data once per change in the model, not once per body.
Frequent body calls are normal and cheap by design. The problem is almost always the work inside them, not the count.
Give every piece of state exactly one owner at the narrowest scope that needs it. Data flows down as values or observable models, and changes flow back up as calls to the owner's methods, never as writes to a copy.
Layers of state
View-local UI state: @State in the view: sheet flags, text being typed, an expanded row.
Feature state: an @MainActor @Observable model owned with @State by the feature's root view: the screen's data, its loading phase, and the user's intents as methods.
App-wide state: session, settings, shared caches. Created once at the App level, injected with .environment(...), read only where needed.
Persistent data: SwiftData, Core Data or a repository; models read from it rather than keeping their own diverging copy.
One-way flow
Views render state and send intents such as cart.add(item); only the owner mutates. Every change is then traceable to one method and testable without a view.
TCA formalises the same idea.
A view model is worth it when the screen has logic to test; services sit behind protocols and are injected through init or the environment.
What breaks at scale
One giant app-wide model every screen reads couples all features to it; split state by feature.
Two copies of one entity, such as a list and a detail each holding their own, drift apart.
Navigation state scattered across views; keep the path in one router model, so a deep link or pop-to-root is a single assignment.