Post

SwiftUI Property Wrappers: Complete Reference Guide

Master SwiftUI data flow with @State, @Binding, @Bindable, @StateObject, @ObservedObject, and @EnvironmentObject.

SwiftUI Property Wrappers: Complete Reference Guide

Two questions determine which property wrapper to use: Does the view own the data? Is it a value type or reference type?

@State: Local Value Ownership

For simple data owned by a single view. Always mark private.

1
2
3
4
5
6
7
8
9
10
11
12
struct CounterView: View {
    @State private var count: Int = 0
    @State private var isEnabled: Bool = false
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
            Toggle("Enable", isOn: $isEnabled)
        }
    }
}

Rules:

  • Value types only (Int, String, Bool, structs) on iOS 14-16
  • iOS 17+: Can store @Observable objects
  • Survives view recreations
  • Use $ to pass as binding

@Binding: Two-Way Reference

Reads and writes data owned elsewhere. No ownership, no default value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ParentView: View {
    @State private var volume: Double = 50
    
    var body: some View {
        VolumeSlider(volume: $volume)  // Pass with $
    }
}

struct VolumeSlider: View {
    @Binding var volume: Double  // Not private, no default
    
    var body: some View {
        Slider(value: $volume, in: 0...100)
    }
}

Rules:

  • Never mark private
  • Never initialize with default value
  • Parent passes with $, child receives without
  • Changes propagate to parent automatically

@StateObject: Class Ownership (iOS 14-16)

Creates and owns an ObservableObject. Survives view recreations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var isLoggedIn: Bool = false
}

struct LoginView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        VStack {
            TextField("Username", text: $viewModel.username)
            Button("Login") { viewModel.isLoggedIn = true }
            ProfileView(viewModel: viewModel)
        }
    }
}

Critical: Never use @ObservedObject var vm = VM(). This recreates the object on every view redraw, losing state.

@ObservedObject: Class Observer (iOS 14-16)

Observes object owned by parent. No lifecycle management.

1
2
3
4
5
6
7
struct ProfileView: View {
    @ObservedObject var viewModel: UserViewModel
    
    var body: some View {
        Text("User: \(viewModel.username)")
    }
}

Rules:

  • Only for objects passed from parent
  • Parent must own with @StateObject or @EnvironmentObject
  • Never create inline: @ObservedObject var vm = VM() causes bugs

@Bindable: Property Bindings (iOS 17+)

Creates bindings to properties of @Observable objects.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Observable
class Book {
    var title: String = ""
    var pageCount: Int = 0
}

struct LibraryView: View {
    @State private var book = Book()
    
    var body: some View {
        BookEditor(book: book)  // Pass without $
    }
}

struct BookEditor: View {
    @Bindable var book: Book
    
    var body: some View {
        Form {
            TextField("Title", text: $book.title)
            Stepper("Pages: \(book.pageCount)", value: $book.pageCount)
        }
    }
}

Rules:

  • Only with @Observable objects (not ObservableObject)
  • Pass object without $
  • Access properties with $book.property
  • Cannot replace object, only modify properties

With @Environment

1
2
3
4
5
6
7
8
struct DetailView: View {
    @Environment(Book.self) private var book
    
    var body: some View {
        @Bindable var book = book
        TextField("Title", text: $book.title)
    }
}

@EnvironmentObject: Global Shared State

Shares data across view hierarchy without explicit passing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AppTheme: ObservableObject {
    @Published var primaryColor: Color = .blue
}

@main
struct MyApp: App {
    @StateObject private var theme = AppTheme()
    
    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(theme)
        }
    }
}

struct SettingsView: View {
    @EnvironmentObject var theme: AppTheme
    
    var body: some View {
        ColorPicker("Theme", selection: $theme.primaryColor)
    }
}

Rules:

  • Use for data needed by many unrelated views
  • Inject at high level with .environmentObject()
  • App crashes if not provided in environment
  • Avoids prop drilling through intermediate views

Decision Matrix

Value Types (Int, String, Bool, Struct)

ScenarioUse
View creates/owns@State
Passed from parent@Binding

Reference Types (Class)

iOS 14-16:

ScenarioUse
View creates@StateObject
Passed from parent@ObservedObject
Shared globally@EnvironmentObject

iOS 17+:

ScenarioUse
View creates@State
Need property bindings@Bindable
Passed without bindingsNo wrapper
Shared globally@Environment

iOS 17+ Migration

The @Observable macro eliminates @Published and simplifies ownership.

Before:

1
2
3
4
5
6
7
class ViewModel: ObservableObject {
    @Published var count: Int = 0
}

struct OldView: View {
    @StateObject private var vm = ViewModel()
}

After:

1
2
3
4
5
6
7
8
@Observable
class ViewModel {
    var count: Int = 0
}

struct ModernView: View {
    @State private var vm = ViewModel()
}

Critical Rules

  1. Ownership: If you create it (= Thing()), you own it (@State/@StateObject)
  2. The $ rule: Use $ to create bindings from @State, access in @Binding
  3. Private rule: Always mark owned properties private
  4. Lifecycle: @StateObject/@State persist across redraws, @ObservedObject doesn’t
  5. iOS 17: Prefer @Observable + @State over ObservableObject + @StateObject

Common Bugs

Bug 1: Using @ObservedObject for creation

1
2
@ObservedObject var vm = VM()  // ❌ Recreates on redraw
@StateObject private var vm = VM()  // ✅ Persists

Bug 2: Missing $ for bindings

1
2
ChildView(value: count)  // ❌ Passes copy
ChildView(value: $count)  // ✅ Passes binding

Bug 3: Making @State public

1
2
@State var data = 0  // ❌ Should be private
@State private var data = 0  // ✅ Owned internally

Sources

☕ Support My Work

If you found this post helpful and want to support more content like this, you can buy me a coffee!

Your support helps me continue creating useful articles and tips for fellow developers. Thank you! 🙏

This post is licensed under CC BY 4.0 by the author.