Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Reviews, improves, and writes SwiftUI code following state management, view composition, performance, and iOS 26+ Liquid Glass best practices.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/state-management.md
1# SwiftUI State Management Reference23## Table of Contents45- [Property Wrapper Selection Guide](#property-wrapper-selection-guide)6- [@State](#state)7- [Property Wrappers Inside @Observable Classes](#property-wrappers-inside-observable-classes)8- [Make @Observable Property Types Equatable](#make-observable-property-types-equatable)9- [@Observable Dependency Granularity](#observable-dependency-granularity)10- [@Binding](#binding)11- [@FocusState](#focusstate)12- [@StateObject vs @ObservedObject (Legacy - Pre-iOS 17)](#stateobject-vs-observedobject-legacy---pre-ios-17)13- [Don't Pass Values as @State](#dont-pass-values-as-state)14- [@Bindable (iOS 17+)](#bindable-ios-17)15- [let vs var for Passed Values](#let-vs-var-for-passed-values)16- [Environment and Preferences](#environment-and-preferences)17- [Decision Flowchart](#decision-flowchart)18- [State Privacy Rules](#state-privacy-rules)19- [Avoid Nested ObservableObject](#avoid-nested-observableobject)20- [Key Principles](#key-principles)2122## Property Wrapper Selection Guide2324| Wrapper | Use When | Notes |25|---------|----------|-------|26| `@State` | Internal view state that triggers updates | Must be `private` |27| `@Binding` | Child view needs to modify parent's state | Don't use for read-only |28| `@Bindable` | iOS 17+: View receives `@Observable` object and needs bindings | For injected observables |29| `let` | Read-only value passed from parent | Simplest option |30| `var` | Read-only value that child observes via `.onChange()` | For reactive reads |3132**Legacy (Pre-iOS 17):**33| Wrapper | Use When | Notes |34|---------|----------|-------|35| `@StateObject` | View owns an `ObservableObject` instance | Use `@State` with `@Observable` instead |36| `@ObservedObject` | View receives an `ObservableObject` from outside | Never create inline |3738## @State3940Always mark `@State` properties as `private`. Use for internal view state that triggers UI updates.4142```swift43// Correct44@State private var isAnimating = false45@State private var selectedTab = 046```4748**Why Private?** Marking state as `private` makes it clear what's created by the view versus what's passed in. It also prevents accidentally passing initial values that will be ignored (see "Don't Pass Values as @State" below).4950### iOS 17+ with @Observable (Preferred)5152**Always prefer `@Observable` over `ObservableObject`.** With iOS 17's `@Observable` macro, use `@State` instead of `@StateObject`:5354```swift55@Observable56@MainActor // Always mark @Observable classes with @MainActor57final class DataModel {58var name = "Some Name"59var count = 060}6162struct MyView: View {63@State private var model = DataModel() // Use @State, not @StateObject6465var body: some View {66VStack {67TextField("Name", text: $model.name)68Stepper("Count: \(model.count)", value: $model.count)69}70}71}72```7374**Critical**: When a view *owns* an `@Observable` object, always use `@State` -- not `let`. Without `@State`, SwiftUI may recreate the instance when a parent view redraws, losing accumulated state. `@State` tells SwiftUI to preserve the instance across view redraws. Using `@State` also provides bindings directly (no need for `@Bindable`).7576**Note**: You may want to mark `@Observable` classes with `@MainActor` to ensure thread safety with SwiftUI, unless your project or package uses Default Actor Isolation set to `MainActor`—in which case, the explicit attribute is redundant and can be omitted.7778## Property Wrappers Inside @Observable Classes7980**Critical**: The `@Observable` macro transforms stored properties to add observation tracking. Property wrappers (like `@AppStorage`, `@SceneStorage`, `@Query`) also transform properties with their own storage. These two transformations conflict, causing a compiler error.8182**Always annotate property-wrapper properties with `@ObservationIgnored` inside `@Observable` classes.**8384```swift85@Observable86@MainActor87final class SettingsModel {88// WRONG - compiler error: property wrappers conflict with @Observable89// @AppStorage("username") var username = ""9091// CORRECT - @ObservationIgnored prevents the conflict92@ObservationIgnored @AppStorage("username") var username = ""93@ObservationIgnored @AppStorage("isDarkMode") var isDarkMode = false9495// Regular stored properties work fine with @Observable96var isLoading = false97}98```99100This applies to **any** property wrapper used inside an `@Observable` class, including but not limited to:101- `@AppStorage`102- `@SceneStorage`103- `@Query` (SwiftData)104105**Note**: Since `@ObservationIgnored` disables observation tracking for that property, SwiftUI won't detect changes through the Observation framework. However, property wrappers like `@AppStorage` already notify SwiftUI of changes through their own mechanisms (e.g., UserDefaults KVO), so views still update correctly.106107**Never remove `@ObservationIgnored`** from property-wrapper properties in `@Observable` classes — doing so causes a compiler error.108109## Make @Observable Property Types Equatable110111The `@Observable` macro generates a setter that **skips invalidation when the new value equals the current one** — but only when it can compare them, which means only when the property's type is `Equatable`. Without that conformance, every assignment notifies observing views, even when the value is identical. This is an easy win for properties written frequently with the same value (polling, streaming updates, timers).112113```swift114// AVOID: not Equatable — every assignment invalidates, even no-op writes115enum DeliveryStatus { case placed, preparing, shipped, delivered }116117// PREFER: Equatable lets the generated setter short-circuit redundant writes118enum DeliveryStatus: Equatable { case placed, preparing, shipped, delivered }119```120121This applies to collection properties too: an `Array`/`Set`/`Dictionary` is only `Equatable` when its element type is, so a non-`Equatable` element defeats the short-circuit for the whole collection. (The check is emitted into the generated setter as user code, so it applies on every OS that supports `@Observable` when built with current Xcode.)122123This is distinct from `Equatable` *views* (see `references/performance-patterns.md`): that conformance lets SwiftUI skip a view's body; this one lets the model skip notifying observers in the first place.124125## @Observable Dependency Granularity126127Observation tracks reads at the **property** level, not the field level — so reading any part of a compound property establishes a dependency on the whole thing. Three common traps and their fixes:128129- **A computed property establishes dependencies transitively.** `var currentUser: User? { users.first { $0.id == currentID } }` reads `users` in its body, so any view reading `currentUser` depends on the entire `users` array. Renaming the access doesn't change what observation tracks.130- **A struct-typed stored property drags the whole struct.** A view reading `session.user.name` depends on `session.user`; editing any other field of `user` invalidates it.131- **An array/collection read drags the whole collection.** Reading one element establishes a dependency on the entire stored collection.132133```swift134// PREFER: cache derived values as stored properties, kept in sync in didSet135@MainActor @Observable136final class AppState {137var users: [User] = [] { didSet { recomputeCurrentUser() } }138var currentID: User.ID? { didSet { recomputeCurrentUser() } }139140private(set) var currentUser: User?141private func recomputeCurrentUser() { currentUser = users.first { $0.id == currentID } }142}143```144145For struct-typed properties, expose the fields the views actually read as individual properties on the model (each is then tracked separately). When many rows each observe several fields of their element, model each element as its own `@Observable` and have the parent **persist** the instances — see the per-item view model pattern in `references/performance-patterns.md`. Reading several already-narrow properties from one model is fine and does not need splitting.146147## @Binding148149Use only when child view needs to **modify** parent's state. If child only reads the value, use `let` instead.150151```swift152// Parent153struct ParentView: View {154@State private var isSelected = false155156var body: some View {157ChildView(isSelected: $isSelected)158}159}160161// Child - will modify the value162struct ChildView: View {163@Binding var isSelected: Bool164165var body: some View {166Button("Toggle") {167isSelected.toggle()168}169}170}171```172173### When NOT to use @Binding174175- **Don't use `@Binding` for read-only values.** If the child only displays the value and never modifies it, use `let` instead. `@Binding` adds unnecessary overhead and implies a write contract that doesn't exist.176177### Prefer KeyPath Bindings Over Closure Bindings178179When you need a binding into a model, prefer a KeyPath/subscript-based binding over a hand-written `Binding(get:set:)` closure. A closure binding allocates a new closure each time `body` runs and can't be compared, which can trigger unnecessary invalidations.180181```swift182// BAD - closure binding: heap allocation each body pass, defeats comparison183let binding = Binding(184get: { model[scoreFor: player] },185set: { model[scoreFor: player] = $0 }186)187PlayerScoreRow(player: player, score: binding)188189// GOOD - project through a subscript with @Bindable190@Bindable var model = model191PlayerScoreRow(player: player, score: $model[scoreFor: player])192```193194If no suitable subscript exists, add one (a labeled subscript reads as a clean projection into the model). Reserve closure bindings for cases where no key path or subscript can express the transform.195196## @FocusState197198See `references/focus-patterns.md` for comprehensive focus management guidance including `@FocusState`, `@FocusedValue`, `.focusable()`, default focus, and common pitfalls.199200Always mark `@FocusState` as `private`.201202## @StateObject vs @ObservedObject (Legacy - Pre-iOS 17)203204**Note**: Always prefer `@Observable` with `@State` for iOS 17+.205206The key distinction is **ownership**: `@StateObject` when the view **creates and owns** the object; `@ObservedObject` when the view **receives** it from outside.207208```swift209// View creates it → @StateObject210@StateObject private var viewModel = MyViewModel()211212// View receives it → @ObservedObject213@ObservedObject var viewModel: MyViewModel214```215216**Never** create an `ObservableObject` inline with `@ObservedObject` -- it recreates the instance on every view update.217218### @StateObject instantiation in View's initializer219220Prefer storing the `@StateObject` in the parent view and passing it down. If you must create one in a custom initializer, pass the expression directly to `StateObject(wrappedValue:)` so the `@autoclosure` prevents redundant allocations:221222```swift223// Inside a View's init(movie:):224// WRONG — assigning to a local first defeats @autoclosure225let vm = MovieDetailsViewModel(movie: movie)226_viewModel = StateObject(wrappedValue: vm)227228// CORRECT — inline expression defers creation229_viewModel = StateObject(wrappedValue: MovieDetailsViewModel(movie: movie))230```231232**Modern Alternative**: Use `@Observable` with `@State` instead.233234## Don't Pass Values as @State235236**Critical**: Never declare passed values as `@State` or `@StateObject`. They only accept an initial value and ignore subsequent updates from the parent.237238```swift239// WRONG - child ignores parent updates240struct ChildView: View {241@State var item: Item // Shows initial value forever!242var body: some View { Text(item.name) }243}244245// CORRECT - child receives updates246struct ChildView: View {247let item: Item // Or @Binding if child needs to modify248var body: some View { Text(item.name) }249}250```251252**Prevention**: Always mark `@State` and `@StateObject` as `private`. This prevents them from appearing in the generated initializer.253254## @Bindable (iOS 17+)255256Use when receiving an `@Observable` object from outside and needing bindings:257258```swift259@Observable260final class UserModel {261var name = ""262var email = ""263}264265struct ParentView: View {266@State private var user = UserModel()267268var body: some View {269EditUserView(user: user)270}271}272273struct EditUserView: View {274@Bindable var user: UserModel // Received from parent, needs bindings275276var body: some View {277Form {278TextField("Name", text: $user.name)279TextField("Email", text: $user.email)280}281}282}283```284285## let vs var for Passed Values286287### Use `let` for read-only display288289```swift290struct ProfileHeader: View {291let username: String292let avatarURL: URL293294var body: some View {295HStack {296AsyncImage(url: avatarURL)297Text(username)298}299}300}301```302303### Use `var` when reacting to changes with `.onChange()`304305```swift306struct ReactiveView: View {307var externalValue: Int // Watch with .onChange()308@State private var displayText = ""309310var body: some View {311Text(displayText)312.onChange(of: externalValue) { oldValue, newValue in313displayText = "Changed from \(oldValue) to \(newValue)"314}315}316}317```318319## Environment and Preferences320321### @Environment322323Access environment values provided by SwiftUI or parent views:324325```swift326struct MyView: View {327@Environment(\.colorScheme) private var colorScheme328@Environment(\.dismiss) private var dismiss329330var body: some View {331Button("Done") { dismiss() }332.foregroundStyle(colorScheme == .dark ? .white : .black)333}334}335```336337### Custom Environment Values with @Entry338339Use the `@Entry` macro (Xcode 16+, backward compatible to iOS 13) to define custom environment values without boilerplate:340341```swift342extension EnvironmentValues {343@Entry var accentTheme: Theme = .default344}345346// Inject347ContentView()348.environment(\.accentTheme, customTheme)349350// Access351struct ThemedView: View {352@Environment(\.accentTheme) private var theme353}354```355356The `@Entry` macro replaces the manual `EnvironmentKey` conformance pattern. It also works with `Transaction`, `ContainerValues`, and `FocusedValues`.357358#### Never store closures in custom environment keys359360SwiftUI can't reliably compare function values, so a view that reads an environment key holding a closure invalidates on every environment write, even when nothing it cares about changed. Wrapping the closure in a struct doesn't help — the struct still contains an uncomparable closure. The fix is to eliminate the closure: store the data it would have captured as properties and expose the behavior via `callAsFunction` or a method.361362```swift363// AVOID: closure in a custom environment key364extension EnvironmentValues {365@Entry var submit: (String) -> Void = { _ in }366}367368// PREFER: a defunctionalized struct (or an @Observable model)369struct SubmitAction { func callAsFunction(_ draft: String) { /* ... */ } }370extension EnvironmentValues {371@Entry var submit = SubmitAction()372}373```374375This rule is for **custom** keys. Framework action types designed to wrap a closure — `\.openURL`, `\.dismiss`, `\.refresh`, and similar — are the intended API and are fine.376377#### Keep @Entry default values stable378379`@Entry` wraps its default expression in a computed getter, so the default is re-evaluated on every read that falls back to it. If the expression returns a different result each time — a fresh reference like `Model()`, or a runtime value like `Date()` / `UUID()` — readers that use the default invalidate on every unrelated environment write.380381```swift382// AVOID: re-allocates Model() on every fallback read383extension EnvironmentValues {384@Entry var model = Model()385}386387// PREFER: back the default with a stable value388extension EnvironmentValues {389@Entry var model = _defaultModel390private static let _defaultModel = Model()391}392```393394Stable defaults (literals, enum cases, `nil`, or a `static let`-backed instance) need no fix. If readers test the default for an "empty" sentinel, prefer an optional `@Entry var model: Model?` and branch on `if let` instead.395396#### Remove unused @Environment reads397398Declaring `@Environment(\.someKey)` subscribes the view to that key even if `body` never uses the value, so every write to that key re-evaluates the view for nothing. If the wrapped value isn't referenced anywhere the body reaches, delete the declaration. (The type form `@Environment(Model.self)` is different — observation tracks at the property level, so an unused type-form declaration carries no live invalidation cost.)399400### @Environment with @Observable (iOS 17+ - Preferred)401402**Always prefer this pattern** for sharing state through the environment:403404```swift405@Observable406@MainActor407final class AppState {408var isLoggedIn = false409}410411// Inject412ContentView()413.environment(AppState())414415// Access416struct ChildView: View {417@Environment(AppState.self) private var appState418}419```420421### @EnvironmentObject (Legacy - Pre-iOS 17)422423Legacy pattern: inject with `.environmentObject(AppState())`, access with `@EnvironmentObject var appState: AppState`. Prefer `@Observable` with `@Environment` instead.424425## Decision Flowchart426427```428Is this value owned by this view?429├─ YES: Is it a simple value type?430│ ├─ YES → @State private var431│ └─ NO (class):432│ ├─ Use @Observable → @State private var (mark class @MainActor)433│ └─ Legacy ObservableObject → @StateObject private var434│435└─ NO (passed from parent):436├─ Does child need to MODIFY it?437│ ├─ YES → @Binding var438│ └─ NO: Does child need BINDINGS to its properties?439│ ├─ YES (@Observable) → @Bindable var440│ └─ NO: Does child react to changes?441│ ├─ YES → var + .onChange()442│ └─ NO → let443│444└─ Is it a legacy ObservableObject from parent?445└─ YES → @ObservedObject var (consider migrating to @Observable)446```447448## State Privacy Rules449450**All view-owned state should be `private`:**451452```swift453// Correct - clear what's created vs passed454struct MyView: View {455// Created by view - private456@State private var isExpanded = false457@State private var viewModel = ViewModel()458@AppStorage("theme") private var theme = "light"459@Environment(\.colorScheme) private var colorScheme460461// Passed from parent - not private462let title: String463@Binding var isSelected: Bool464@Bindable var user: User465466var body: some View {467// ...468}469}470```471472**Why**: This makes dependencies explicit and improves code completion for the generated initializer.473474## Avoid Nested ObservableObject475476**Note**: This limitation only applies to `ObservableObject`. `@Observable` fully supports nested observed objects.477478SwiftUI can't track changes through nested `ObservableObject` properties. Workaround: pass the nested object directly to child views as `@ObservedObject`. With `@Observable`, nesting works automatically.479480## Key Principles4814821. **Always prefer `@Observable` over `ObservableObject`** for new code4832. **Mark `@Observable` classes with `@MainActor` for thread safety (unless using default actor isolation)`**4843. Use `@State` with `@Observable` classes (not `@StateObject`)4854. Use `@Bindable` for injected `@Observable` objects that need bindings4865. **Always mark `@State` and `@StateObject` as `private`**4876. **Never declare passed values as `@State` or `@StateObject`**4887. With `@Observable`, nested objects work fine; with `ObservableObject`, pass nested objects directly to child views4898. **Always add `@ObservationIgnored` to property wrappers** (e.g., `@AppStorage`, `@SceneStorage`, `@Query`) inside `@Observable` classes — they conflict with the macro's property transformation4909. **Prefer `Equatable` types for frequently-written `@Observable` properties** so the generated setter skips redundant invalidations49110. **Never store closures in custom environment keys; keep `@Entry` defaults stable** (no `Model()`/`Date()` expressions)49211. **Prefer KeyPath/subscript bindings over closure bindings**493