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- [@Binding](#binding)9- [@FocusState](#focusstate)10- [@StateObject vs @ObservedObject (Legacy - Pre-iOS 17)](#stateobject-vs-observedobject-legacy---pre-ios-17)11- [Don't Pass Values as @State](#dont-pass-values-as-state)12- [@Bindable (iOS 17+)](#bindable-ios-17)13- [let vs var for Passed Values](#let-vs-var-for-passed-values)14- [Environment and Preferences](#environment-and-preferences)15- [Decision Flowchart](#decision-flowchart)16- [State Privacy Rules](#state-privacy-rules)17- [Avoid Nested ObservableObject](#avoid-nested-observableobject)18- [Key Principles](#key-principles)1920## Property Wrapper Selection Guide2122| Wrapper | Use When | Notes |23|---------|----------|-------|24| `@State` | Internal view state that triggers updates | Must be `private` |25| `@Binding` | Child view needs to modify parent's state | Don't use for read-only |26| `@Bindable` | iOS 17+: View receives `@Observable` object and needs bindings | For injected observables |27| `let` | Read-only value passed from parent | Simplest option |28| `var` | Read-only value that child observes via `.onChange()` | For reactive reads |2930**Legacy (Pre-iOS 17):**31| Wrapper | Use When | Notes |32|---------|----------|-------|33| `@StateObject` | View owns an `ObservableObject` instance | Use `@State` with `@Observable` instead |34| `@ObservedObject` | View receives an `ObservableObject` from outside | Never create inline |3536## @State3738Always mark `@State` properties as `private`. Use for internal view state that triggers UI updates.3940```swift41// Correct42@State private var isAnimating = false43@State private var selectedTab = 044```4546**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).4748### iOS 17+ with @Observable (Preferred)4950**Always prefer `@Observable` over `ObservableObject`.** With iOS 17's `@Observable` macro, use `@State` instead of `@StateObject`:5152```swift53@Observable54@MainActor // Always mark @Observable classes with @MainActor55final class DataModel {56var name = "Some Name"57var count = 058}5960struct MyView: View {61@State private var model = DataModel() // Use @State, not @StateObject6263var body: some View {64VStack {65TextField("Name", text: $model.name)66Stepper("Count: \(model.count)", value: $model.count)67}68}69}70```7172**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`).7374**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.7576## Property Wrappers Inside @Observable Classes7778**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.7980**Always annotate property-wrapper properties with `@ObservationIgnored` inside `@Observable` classes.**8182```swift83@Observable84@MainActor85final class SettingsModel {86// WRONG - compiler error: property wrappers conflict with @Observable87// @AppStorage("username") var username = ""8889// CORRECT - @ObservationIgnored prevents the conflict90@ObservationIgnored @AppStorage("username") var username = ""91@ObservationIgnored @AppStorage("isDarkMode") var isDarkMode = false9293// Regular stored properties work fine with @Observable94var isLoading = false95}96```9798This applies to **any** property wrapper used inside an `@Observable` class, including but not limited to:99- `@AppStorage`100- `@SceneStorage`101- `@Query` (SwiftData)102103**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.104105**Never remove `@ObservationIgnored`** from property-wrapper properties in `@Observable` classes — doing so causes a compiler error.106107## @Binding108109Use only when child view needs to **modify** parent's state. If child only reads the value, use `let` instead.110111```swift112// Parent113struct ParentView: View {114@State private var isSelected = false115116var body: some View {117ChildView(isSelected: $isSelected)118}119}120121// Child - will modify the value122struct ChildView: View {123@Binding var isSelected: Bool124125var body: some View {126Button("Toggle") {127isSelected.toggle()128}129}130}131```132133### When NOT to use @Binding134135- **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.136137## @FocusState138139See `references/focus-patterns.md` for comprehensive focus management guidance including `@FocusState`, `@FocusedValue`, `.focusable()`, default focus, and common pitfalls.140141Always mark `@FocusState` as `private`.142143## @StateObject vs @ObservedObject (Legacy - Pre-iOS 17)144145**Note**: Always prefer `@Observable` with `@State` for iOS 17+.146147The key distinction is **ownership**: `@StateObject` when the view **creates and owns** the object; `@ObservedObject` when the view **receives** it from outside.148149```swift150// View creates it → @StateObject151@StateObject private var viewModel = MyViewModel()152153// View receives it → @ObservedObject154@ObservedObject var viewModel: MyViewModel155```156157**Never** create an `ObservableObject` inline with `@ObservedObject` -- it recreates the instance on every view update.158159### @StateObject instantiation in View's initializer160161Prefer 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:162163```swift164// Inside a View's init(movie:):165// WRONG — assigning to a local first defeats @autoclosure166let vm = MovieDetailsViewModel(movie: movie)167_viewModel = StateObject(wrappedValue: vm)168169// CORRECT — inline expression defers creation170_viewModel = StateObject(wrappedValue: MovieDetailsViewModel(movie: movie))171```172173**Modern Alternative**: Use `@Observable` with `@State` instead.174175## Don't Pass Values as @State176177**Critical**: Never declare passed values as `@State` or `@StateObject`. They only accept an initial value and ignore subsequent updates from the parent.178179```swift180// WRONG - child ignores parent updates181struct ChildView: View {182@State var item: Item // Shows initial value forever!183var body: some View { Text(item.name) }184}185186// CORRECT - child receives updates187struct ChildView: View {188let item: Item // Or @Binding if child needs to modify189var body: some View { Text(item.name) }190}191```192193**Prevention**: Always mark `@State` and `@StateObject` as `private`. This prevents them from appearing in the generated initializer.194195## @Bindable (iOS 17+)196197Use when receiving an `@Observable` object from outside and needing bindings:198199```swift200@Observable201final class UserModel {202var name = ""203var email = ""204}205206struct ParentView: View {207@State private var user = UserModel()208209var body: some View {210EditUserView(user: user)211}212}213214struct EditUserView: View {215@Bindable var user: UserModel // Received from parent, needs bindings216217var body: some View {218Form {219TextField("Name", text: $user.name)220TextField("Email", text: $user.email)221}222}223}224```225226## let vs var for Passed Values227228### Use `let` for read-only display229230```swift231struct ProfileHeader: View {232let username: String233let avatarURL: URL234235var body: some View {236HStack {237AsyncImage(url: avatarURL)238Text(username)239}240}241}242```243244### Use `var` when reacting to changes with `.onChange()`245246```swift247struct ReactiveView: View {248var externalValue: Int // Watch with .onChange()249@State private var displayText = ""250251var body: some View {252Text(displayText)253.onChange(of: externalValue) { oldValue, newValue in254displayText = "Changed from \(oldValue) to \(newValue)"255}256}257}258```259260## Environment and Preferences261262### @Environment263264Access environment values provided by SwiftUI or parent views:265266```swift267struct MyView: View {268@Environment(\.colorScheme) private var colorScheme269@Environment(\.dismiss) private var dismiss270271var body: some View {272Button("Done") { dismiss() }273.foregroundStyle(colorScheme == .dark ? .white : .black)274}275}276```277278### Custom Environment Values with @Entry279280Use the `@Entry` macro (Xcode 16+, backward compatible to iOS 13) to define custom environment values without boilerplate:281282```swift283extension EnvironmentValues {284@Entry var accentTheme: Theme = .default285}286287// Inject288ContentView()289.environment(\.accentTheme, customTheme)290291// Access292struct ThemedView: View {293@Environment(\.accentTheme) private var theme294}295```296297The `@Entry` macro replaces the manual `EnvironmentKey` conformance pattern. It also works with `TransactionValues`, `ContainerValues`, and `FocusedValues`.298299### @Environment with @Observable (iOS 17+ - Preferred)300301**Always prefer this pattern** for sharing state through the environment:302303```swift304@Observable305@MainActor306final class AppState {307var isLoggedIn = false308}309310// Inject311ContentView()312.environment(AppState())313314// Access315struct ChildView: View {316@Environment(AppState.self) private var appState317}318```319320### @EnvironmentObject (Legacy - Pre-iOS 17)321322Legacy pattern: inject with `.environmentObject(AppState())`, access with `@EnvironmentObject var appState: AppState`. Prefer `@Observable` with `@Environment` instead.323324## Decision Flowchart325326```327Is this value owned by this view?328├─ YES: Is it a simple value type?329│ ├─ YES → @State private var330│ └─ NO (class):331│ ├─ Use @Observable → @State private var (mark class @MainActor)332│ └─ Legacy ObservableObject → @StateObject private var333│334└─ NO (passed from parent):335├─ Does child need to MODIFY it?336│ ├─ YES → @Binding var337│ └─ NO: Does child need BINDINGS to its properties?338│ ├─ YES (@Observable) → @Bindable var339│ └─ NO: Does child react to changes?340│ ├─ YES → var + .onChange()341│ └─ NO → let342│343└─ Is it a legacy ObservableObject from parent?344└─ YES → @ObservedObject var (consider migrating to @Observable)345```346347## State Privacy Rules348349**All view-owned state should be `private`:**350351```swift352// Correct - clear what's created vs passed353struct MyView: View {354// Created by view - private355@State private var isExpanded = false356@State private var viewModel = ViewModel()357@AppStorage("theme") private var theme = "light"358@Environment(\.colorScheme) private var colorScheme359360// Passed from parent - not private361let title: String362@Binding var isSelected: Bool363@Bindable var user: User364365var body: some View {366// ...367}368}369```370371**Why**: This makes dependencies explicit and improves code completion for the generated initializer.372373## Avoid Nested ObservableObject374375**Note**: This limitation only applies to `ObservableObject`. `@Observable` fully supports nested observed objects.376377SwiftUI can't track changes through nested `ObservableObject` properties. Workaround: pass the nested object directly to child views as `@ObservedObject`. With `@Observable`, nesting works automatically.378379## Key Principles3803811. **Always prefer `@Observable` over `ObservableObject`** for new code3822. **Mark `@Observable` classes with `@MainActor` for thread safety (unless using default actor isolation)`**3833. Use `@State` with `@Observable` classes (not `@StateObject`)3844. Use `@Bindable` for injected `@Observable` objects that need bindings3855. **Always mark `@State` and `@StateObject` as `private`**3866. **Never declare passed values as `@State` or `@StateObject`**3877. With `@Observable`, nested objects work fine; with `ObservableObject`, pass nested objects directly to child views3888. **Always add `@ObservationIgnored` to property wrappers** (e.g., `@AppStorage`, `@SceneStorage`, `@Query`) inside `@Observable` classes — they conflict with the macro's property transformation389