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
Head of Development & Engineering
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.