SwiftUI Property Wrappers: Complete Reference Guide
Master SwiftUI data flow with @State, @Binding, @Bindable, @StateObject, @ObservedObject, and @EnvironmentObject.
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)
| Scenario | Use |
|---|---|
| View creates/owns | @State |
| Passed from parent | @Binding |
Reference Types (Class)
iOS 14-16:
| Scenario | Use |
|---|---|
| View creates | @StateObject |
| Passed from parent | @ObservedObject |
| Shared globally | @EnvironmentObject |
iOS 17+:
| Scenario | Use |
|---|---|
| View creates | @State |
| Need property bindings | @Bindable |
| Passed without bindings | No 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
- Ownership: If you create it (
= Thing()), you own it (@State/@StateObject) - The
$rule: Use$to create bindings from @State, access in @Binding - Private rule: Always mark owned properties
private - Lifecycle: @StateObject/@State persist across redraws, @ObservedObject doesn’t
- 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! 🙏