AG
Writing
Engineering3 min read

SwiftUI Property Wrappers, with Real-World Examples

A practical tour of SwiftUI's core property wrappers — @State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject, @AppStorage, and @Environment — and exactly when to reach for each.

Charith 'Alex' Gunasekara

Charith 'Alex' Gunasekara

Head of Development & Engineering

Originally published on Medium
SwiftUIiOSState ManagementMobile

SwiftUI's reactivity lives in its property wrappers. Get them right and state management is effortless; get them wrong and you get stale views, lost data, or objects that quietly re-initialise. Here's each of the core wrappers with a real example and a clear rule for when to use it.

@State — local view state

@State owns simple, mutable state inside a single view. When the value changes, SwiftUI re-renders automatically.

struct LightView: View {
    @State private var isOn = false
 
    var body: some View {
        VStack(spacing: 16) {
            Text(isOn ? "💡 Light is ON" : "🌙 Light is OFF")
                .font(.title2)
            Button("Toggle") {
                isOn.toggle()
            }
        }
    }
}

Think of @State as a private variable inside the view — perfect for toggles, counters, and any UI-driven state change.

@Binding — a two-way connection to parent state

@Binding hands a child read/write access to a parent's @State without owning it. The parent passes the value with a $ prefix.

struct ParentView: View {
    @State private var isOn = false
 
    var body: some View {
        VStack {
            Text("Light is \(isOn ? "ON" : "OFF")")
            ChildSwitchView(isOn: $isOn)
        }
    }
}
 
struct ChildSwitchView: View {
    @Binding var isOn: Bool
 
    var body: some View {
        Toggle("Power", isOn: $isOn)
            .padding()
    }
}

Use @Binding when you want to share control over a value between views — checkboxes, sliders, or toggles in reusable components.

@StateObject — owning an ObservableObject

When a view creates and owns a reference-type model, use @StateObject. It's instantiated once and survives re-renders.

class CounterModel: ObservableObject {
    @Published var count = 0
}
 
struct CounterView: View {
    @StateObject private var counter = CounterModel()
 
    var body: some View {
        VStack {
            Text("Count: \(counter.count)")
            Button("Increment") {
                counter.count += 1
            }
        }
    }
}

If your view is responsible for creating the object, use @StateObject — otherwise you risk unexpected re-initialisation with @ObservedObject.

@ObservedObject — observing a model owned elsewhere

When the model is created somewhere else and passed in, use @ObservedObject. The view observes and reacts, but doesn't own the lifecycle.

class TimerModel: ObservableObject {
    @Published var seconds = 0
}
 
struct ParentTimerView: View {
    @StateObject private var timer = TimerModel()
 
    var body: some View {
        TimerDisplay(timer: timer)
    }
}
 
struct TimerDisplay: View {
    @ObservedObject var timer: TimerModel
 
    var body: some View {
        Text("Elapsed: \(timer.seconds)s")
    }
}

Used when a view needs to observe and interact with a shared model owned elsewhere.

@EnvironmentObject — shared, app-wide state

@EnvironmentObject injects a model into the environment so any descendant can read it — no manual passing through every layer.

class AppSettings: ObservableObject {
    @Published var isDarkMode = false
}
 
@main
struct MyApp: App {
    @StateObject private var settings = AppSettings()
 
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(settings)
        }
    }
}
 
struct SettingsToggleView: View {
    @EnvironmentObject var settings: AppSettings
 
    var body: some View {
        Toggle("Dark Mode", isOn: $settings.isDarkMode)
    }
}

Best for shared models like user preferences, authentication state, or theme settings across multiple views or tabs.

@AppStorage — persistent UserDefaults

@AppStorage binds a value directly to UserDefaults, so it persists across launches with zero boilerplate.

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

Ideal for user preferences, login flags, or onboarding-completion state.

@Environment — system values

@Environment reads system-provided values — color scheme, locale, dynamic type, dismiss action, and more.

struct ThemeAwareView: View {
    @Environment(\.colorScheme) private var colorScheme
 
    var body: some View {
        Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
    }
}

Use @Environment to read environment values — not for app-specific state management.

Takeaway

SwiftUI's property wrappers are the backbone of reactive, maintainable UI. Choose the right wrapper for the right scenario — own with @StateObject, observe with @ObservedObject, share with @EnvironmentObject, persist with @AppStorage — and your state management stays simple, clear, and scalable.

ShareLinkedInX

Keep reading