SwiftUI State: Ownership Rules End View Redraw Bugs

Treat SwiftUI views as functions of state (UI = f(state)). Choose wrappers by ownership: @State for local simple values, @Binding to share edits, @StateObject for view-owned models, @ObservedObject for injected ones. Compute derived state, persist with @AppStorage.

Views as Functions of State: Redraws Are Features, Not Bugs

SwiftUI views are structs that act like pure functions: UI = f(state). When state changes, SwiftUI re-runs the body computation and redraws only affected parts—no manual reloadData() needed. State is any value the UI depends on, like a counter, toggle, or form validity.

Use @State for local, simple state owned by the view (Bool, Int, String, small structs). It persists across redraws because SwiftUI stores it stably:

import SwiftUI
struct CounterView: View {
    @State private var count = 0
    var body: some View {
        VStack(spacing: 16) {
            Text("Count: \(count)")
                .font(.title)
            Button("Tap Me") {
                count += 1
            }
        }
        .padding()
    }
}

Normal variables reset on redraws since views are disposable. For UI toggles:

struct ShowDetailsView: View {
    @State private var showDetails = false
    var body: some View {
        VStack(spacing: 16) {
            Button(showDetails ? "Hide Details" : "Show Details") {
                showDetails.toggle()
            }
            if showDetails {
                Text("Here are the details you definitely won't read.")
                    .padding()
                    .background(.thinMaterial)
                    .cornerRadius(12)
            }
        }
        .padding()
    }
}

Never store derived state—compute it to avoid sync bugs. Example form validation:

struct SignupView: View {
    @State private var email = ""
    @State private var password = ""
    private var isValid: Bool {
        email.contains("@") && password.count >= 8
    }
    // ...
    Button("Create Account")
        .disabled(!isValid)
}

Sharing and Model State: Bindings and Observable Objects

Pass ownership with @Binding: parent holds @State, child gets a binding like a remote control:

struct ParentView: View {
    @State private var isOn = false
    var body: some View {
        VStack {
            Text(isOn ? "On" : "Off")
            OnOffButton(isOn: $isOn)
        }
    }
}
struct OnOffButton: View {
    @Binding var isOn: Bool
    var body: some View {
        Button(isOn ? "Turn Off" : "Turn On") {
            isOn.toggle()
        }
    }
}

For complex state, use ObservableObject with @Published properties. View creating the object uses @StateObject to prevent recreation on redraws:

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var name: String = "Sanjay"
    @Published var status: String = "Trying not to over-engineer"
    func updateStatus() {
        status = "Still trying. Still failing."
    }
}

struct ProfileView: View {
    @StateObject private var vm = ProfileViewModel()
    // ...
    Button("Update Status") { vm.updateStatus() }
}

Injected objects use @ObservedObject. Avoid @ObservedObject var vm = ProfileViewModel()—it recreates and resets data/API calls.

For app-wide state (user login, theme), inject @EnvironmentObject at root:

struct RootView: View {
    @StateObject private var appState = AppState()
    var body: some View {
        ContentView()
            .environmentObject(appState)
    }
}
// In any descendant:
@EnvironmentObject var appState: AppState

Limit to true globals; they hide dependencies.

Persistence and Decision Framework: Survive Restarts

Persist simple values across app kills with @AppStorage (UserDefaults-backed):

struct SettingsView: View {
    @AppStorage("isDarkMode") private var isDarkMode = false
    var body: some View {
        Toggle("Dark Mode", isOn: $isDarkMode)
    }
}

For scene/window-specific UI (drafts, tabs), use @SceneStorage:

struct NotesView: View {
    @SceneStorage("draftText") private var draftText = ""
    var body: some View {
        TextEditor(text: $draftText)
    }
}

Decision checklist by ownership/lifetime:

  • Local/simple: @State
  • Child edits: @Binding
  • View-owned model: @StateObject
  • Injected model: @ObservedObject
  • App-shared: @EnvironmentObject (sparingly)
  • App-persistent: @AppStorage
  • Scene-persistent: @SceneStorage

Pitfalls That Cause 90% of State Confusion

Common bugs: storing computed values (leads to staleness), wrong object wrapper (data resets), overusing globals (debug hell), fearing redraws (they're efficient). Answer: Who owns it? How long does it live? This shifts state from magic to predictable tools.

Summarized by x-ai/grok-4.1-fast via openrouter

6903 input / 2144 output tokens in 14144ms

© 2026 Edge