SwiftUI State Management: @State, @Binding, @ObservedObject, @EnvironmentObject
SwiftUI state management is how your app stores changing values and keeps the user interface in sync with them. This article explains the four core tools you will use most often: @State, @Binding, @ObservedObject, and @EnvironmentObject.
Quick answer: Use @State for private view-owned data, @Binding to pass editable state to a child view, @ObservedObject to watch a reference-type model owned elsewhere, and @EnvironmentObject for shared app-wide observable data injected through the view tree.
Difficulty: Intermediate
You’ll understand this better if you know: basic Swift variables and constants, how SwiftUI views are rebuilt, and the difference between value types and reference types.
1. What Is SwiftUI State Management?
SwiftUI state management is the system that decides where data lives, who can change it, and which views should redraw when it changes. In SwiftUI, views are lightweight descriptions of the interface, so the data that drives them must be stored in the right place.
- @State stores local, view-owned mutable data.
- @Binding creates a two-way connection to state owned by another view.
- @ObservedObject watches an external class that publishes changes.
- @EnvironmentObject shares an observable object down a view hierarchy without passing it through every initializer.
These tools are not interchangeable. The main question is always: which layer owns the data, and who needs to read or edit it?
2. Why SwiftUI State Management Matters
SwiftUI updates the interface automatically when the data it depends on changes. If you store state in the wrong place, the UI may not refresh, edits may not propagate, or you may end up with duplicated sources of truth.
Good state management helps you:
- keep views predictable and easier to test,
- avoid stale UI that does not reflect the model,
- share data cleanly between parent and child views,
- separate local UI concerns from app-wide data.
It also prevents common SwiftUI confusion, such as wondering why a value resets when a view redraws or why a child view cannot update a parent value.
3. Basic Syntax or Core Idea
At a high level, each wrapper gives SwiftUI a different kind of connection to data. The minimal examples below show the core idea without extra app logic.
@State for view-owned values
Use @State when a view owns the value and changes it locally.
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}This works because SwiftUI keeps the @State storage alive even though the view value itself is recreated.
@Binding for two-way child editing
Use @Binding when a child view needs to read and write a value owned by another view.
import SwiftUI
struct NameEditor: View {
@Binding var name: String
var body: some View {
TextField("Name", text: $name)
}
}The $ prefix converts a state value into a binding.
@ObservedObject for a shared model
Use @ObservedObject when a reference-type model publishes changes and the view should redraw when those changes occur.
import SwiftUI
final class ProfileViewModel: ObservableObject {
@Published var username = "Guest"
}
struct ProfileView: View {
@ObservedObject var viewModel: ProfileViewModel
var body: some View {
Text(viewModel.username)
}
}When username changes, the view refreshes automatically.
@EnvironmentObject for shared app state
Use @EnvironmentObject to access a shared observable object that was injected above in the view hierarchy.
import SwiftUI
final class SessionStore: ObservableObject {
@Published var isLoggedIn = false
}
struct DashboardView: View {
@EnvironmentObject var session: SessionStore
var body: some View {
Text(session.isLoggedIn ? "Welcome" : "Please log in")
}
}This lets many views use the same shared object without threading it through every initializer.
4. Step-by-Step Examples
These examples show how the four wrappers behave in realistic SwiftUI scenarios.
Example 1: Toggling a local setting with @State
A simple switch is a classic @State use case because the view owns the value and no other view needs to edit it.
import SwiftUI
struct NotificationsView: View {
@State private var notificationsEnabled = true
var body: some View {
Form {
Toggle("Enable notifications", isOn: $notificationsEnabled)
Text(notificationsEnabled ? "On" : "Off")
}
}
}Using @State here keeps the view simple and avoids unnecessary shared objects.
Example 2: Editing a parent value in a child view with @Binding
When a child view needs to edit data that belongs to the parent, pass the value as a binding.
import SwiftUI
struct ProfileForm: View {
@State private var displayName = "Ava"
var body: some View {
VStack {
NameField(name: $displayName)
Text("Preview: \(displayName)")
}
.padding()
}
}
struct NameField: View {
@Binding var name: String
var body: some View {
TextField("Display name", text: $name)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}The child view does not own the value; it only edits the parent’s state through the binding.
Example 3: Fetching and displaying shared model data with @ObservedObject
Use @ObservedObject when the model exists outside the view and publishes updates.
import SwiftUI
final class CartViewModel: ObservableObject {
@Published var items: [String] = ["Apple", "Orange"]
var itemCount: Int {
items.count
}
}
struct CartSummaryView: View {
@ObservedObject var viewModel: CartViewModel
var body: some View {
VStack {
Text("Items in cart: \(viewModel.itemCount)")
ForEach(viewModel.items, id: \.self) { item in
Text(item)
}
}
}
}Any change to a @Published property triggers a redraw.
Example 4: Injecting shared session data with @EnvironmentObject
Shared app data such as login state or user settings often belongs in the environment.
import SwiftUI
final class SettingsStore: ObservableObject {
@Published var showTips = true
}
struct SettingsScreen: View {
@EnvironmentObject var settings: SettingsStore
var body: some View {
Toggle("Show tips", isOn: $settings.showTips)
}
}
struct RootView: View {
@StateObject private var settings = SettingsStore()
var body: some View {
SettingsScreen()
.environmentObject(settings)
}
}The root view creates the object, and descendants read it through the environment.
5. Practical Use Cases
These wrappers show up in different parts of a real SwiftUI app:
- @State for form fields, selected tabs, sheet visibility, toggle states, and temporary UI flags.
- @Binding for child controls like reusable text fields, pickers, and sliders that edit parent-owned data.
- @ObservedObject for view models that load data, track a shopping cart, manage a download, or expose network state.
- @EnvironmentObject for logged-in user sessions, global theme settings, accessibility preferences, and shared app coordination.
A useful rule is: if only one view owns the value, start with @State. If another view must edit it, add @Binding. If the data belongs to a class-based model, use @ObservedObject or @EnvironmentObject depending on how widely the object should be shared.
6. Common Mistakes
Mistake 1: Using @State for shared model data
@State is ideal for local view-owned values, but it is a poor fit for shared reference-type data that needs to survive across multiple views.
Problem: Creating a shared model inside a plain @State property can lead to confusing resets or missing update behavior because SwiftUI expects state to be value-like local storage, not an externally observed class.
struct BadProfileView: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
Text(viewModel.username)
}
}Fix: Use @StateObject when the view creates and owns an observable object, or pass the object in as @ObservedObject if something else owns it.
struct GoodProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
Text(viewModel.username)
}
}The corrected version works because the observable object has stable ownership.
Mistake 2: Passing a normal value where a @Binding is required
A child view declared with @Binding needs a binding at initialization time, not a plain value.
Problem: Swift reports an error such as “Cannot convert value of type 'String' to expected argument type 'Binding<String>'” because the child expects two-way access to the source of truth.
struct ParentView: View {
@State private var name = "Mina"
var body: some View {
NameField(name: name)
}
}Fix: Pass the projected value with $ so SwiftUI can create a binding.
struct ParentView: View {
@State private var name = "Mina"
var body: some View {
NameField(name: $name)
}
}The fixed version gives the child a live connection to the parent’s state.
Mistake 3: Forgetting to provide an @EnvironmentObject
@EnvironmentObject works only after the object has been injected into the view hierarchy.
Problem: If the environment object is missing, SwiftUI usually crashes at runtime with a message like “No ObservableObject of type ... found. A View.environmentObject(_:) for ... may be missing as an ancestor of this view.”
struct AppRoot: View {
var body: some View {
SettingsScreen()
}
}Fix: Create the shared object at a high level and inject it with environmentObject(_:) .
struct AppRoot: View {
@StateObject private var settings = SettingsStore()
var body: some View {
SettingsScreen()
.environmentObject(settings)
}
}Once the object is injected, descendant views can read it safely.
7. Best Practices
Practice 1: Keep ownership close to the source of truth
Choose the narrowest wrapper that still expresses the data flow clearly. Local UI flags belong in @State; cross-view shared data should live in an observable object.
struct BetterSearchView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
}
}This keeps the data model easier to reason about and reduces unnecessary shared state.
Practice 2: Use @ObservedObject for injected objects, not created ones
If the view creates the object itself, prefer @StateObject. If the object comes from outside, use @ObservedObject so the view can respond to updates without taking ownership.
struct OrdersView: View {
@ObservedObject var viewModel: CartViewModel
var body: some View {
Text("\(viewModel.items.count) items")
}
}This avoids accidental re-creation of the object and makes ownership explicit.
Practice 3: Reserve @EnvironmentObject for truly shared app state
Environment objects are powerful, but overusing them can hide dependencies and make views harder to reuse or preview. Use them for broad app concerns, not for every piece of data.
struct ThemeToggleView: View {
@EnvironmentObject var settings: SettingsStore
var body: some View {
Toggle("Show tips", isOn: $settings.showTips)
}
}This approach is best when many distant views need the same model and passing it manually would be noisy.
8. Limitations and Edge Cases
- @State should not be used for data that must outlive the view instance or be shared across unrelated views.
- @Binding is only a connection; it does not store data itself.
- @ObservedObject requires a class that conforms to ObservableObject and publishes change notifications.
- @EnvironmentObject can feel implicit, which makes missing injections harder to spot until runtime.
- SwiftUI may rebuild views often, so relying on a plain stored property for mutable UI data usually leads to resets.
- If a published change happens off the main thread, you may see warnings about publishing changes from background threads; UI-facing state should be updated on the main actor.
A common source of confusion is that the view can be recreated many times, but the wrapped state is preserved by SwiftUI when you use the correct property wrapper.
9. Practical Mini Project
Here is a small but complete SwiftUI screen that combines all four wrappers in a realistic way. The parent owns local draft data, the child edits it, and a shared store controls app-wide login state.
import SwiftUI
final class AppSession: ObservableObject {
@Published var isLoggedIn = true
}
struct ProfileEditor: View {
@Binding var name: String
var body: some View {
TextField("Name", text: $name)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
struct ProfileScreen: View {
@State private var draftName = "Taylor"
@EnvironmentObject var session: AppSession
var body: some View {
VStack(spacing: 16) {
Text(session.isLoggedIn ? "Logged in" : "Logged out")
ProfileEditor(name: $draftName)
Text("Preview: \(draftName)")
Button("Toggle Session") {
session.isLoggedIn.toggle()
}
}
.padding()
}
}
struct ContentView: View {
@StateObject private var session = AppSession()
var body: some View {
ProfileScreen()
.environmentObject(session)
}
}This example shows the full data flow: @State owns the draft name, @Binding lets the child edit it, and @EnvironmentObject provides shared session state from above.
10. Key Points
- @State is for local, view-owned mutable values.
- @Binding is a two-way reference to another view’s state.
- @ObservedObject listens to an external observable class.
- @EnvironmentObject shares an observable object through the view hierarchy.
- The right choice depends on ownership, sharing needs, and how far the data must travel.
- Missing bindings or missing environment injection are two of the most common SwiftUI errors.
11. Practice Exercise
- Create a parent view with a @State Boolean named isExpanded.
- Pass that value into a child view as a @Binding so the child can toggle it.
- Add a class-based ObservableObject with one @Published string property.
- Inject that object into the environment from the root view and read it in a descendant view.
Expected output: a screen where tapping a child button expands or collapses content, and a shared label updates from the environment object.
Hint: The parent owns the boolean, the child receives $isExpanded, and the shared object should be created once with @StateObject at the top level.
import SwiftUI
final class StatusStore: ObservableObject {
@Published var message = "Ready"
}
struct ToggleChildView: View {
@Binding var isExpanded: Bool
var body: some View {
Button("Toggle Details") {
isExpanded.toggle()
}
}
}
struct ExerciseView: View {
@State private var isExpanded = false
@EnvironmentObject var status: StatusStore
var body: some View {
VStack {
Text(status.message)
ToggleChildView(isExpanded: $isExpanded)
if isExpanded {
Text("Details are visible")
}
}
}
}
struct ExerciseRootView: View {
@StateObject private var status = StatusStore()
var body: some View {
ExerciseView()
.environmentObject(status)
}
}12. Final Summary
SwiftUI state management is mostly about ownership and data flow. @State stores local mutable values inside a view, while @Binding gives another view a way to edit that value without owning it. Together, they cover most parent-child communication in SwiftUI.
For shared reference-type data, move to @ObservedObject or @EnvironmentObject. Use @ObservedObject when a view is given an observable model from outside, and use @EnvironmentObject when many descendants need the same shared app state. If you remember who owns the data and who needs to mutate it, choosing the right wrapper becomes much easier.
Next, practice by building a small form screen that uses all four wrappers together. The more you work with real view hierarchies, the more natural SwiftUI’s data flow becomes.