State Management in SwiftUI: `@State`, `@Binding`, `@Observable`, and `@Environment`
In Inside Out, Riley’s emotions change constantly — Joy takes the lead, then Sadness, then Anger — and every shift immediately changes how Riley behaves. SwiftUI works the same way: when your app’s state changes, the interface updates automatically to reflect the new data.
This guide covers the four essential state tools: @State for local values, @Binding for sharing between views,
@Observable for external data models, and @Environment for app-wide values. We won’t cover advanced data flow
patterns like @EnvironmentObject or custom environment keys — those get their own dedicated posts.
This post assumes you are comfortable with SwiftUI basics and understand property wrappers.
What You’ll Learn
- What Is State?
- Local State with @State
- Sharing State with @Binding
- External Data with @Observable
- App-Wide Values with @Environment
- Common Mistakes
- What’s Next?
What Is State?
State is any data that can change over time and affects what your view displays. A counter that increases when you tap a button, a toggle that switches between light and dark mode, a text field where the user types their name — all of these are state.
In SwiftUI, the framework watches your state. When state changes, SwiftUI automatically re-renders the views that depend on it. You never manually tell a label to update its text — you change the data, and the UI follows.
Think of it like the control console in Inside Out. When Joy pushes a button (state changes), Riley’s behavior (the UI) updates immediately. You don’t reprogram Riley’s brain — you just change which emotion is active.
Local State with @State
@State is the simplest way to store state inside a view. It tells SwiftUI: “Watch this value. When it changes, redraw
my view.”
Apple Docs: State — SwiftUI
struct MovieCounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Pixar movies watched: \(count)")
Button("Watch another") {
count += 1
}
}
}
}
Pixar movies watched: 0
[Watch another]
(after tapping the button three times)
Pixar movies watched: 3
Here is what happens step by step:
@State private var count = 0creates a state variable with an initial value of 0.Text("Pixar movies watched: \(count)")reads that value and displays it.- When the button is tapped,
count += 1changes the state. - SwiftUI detects the change and re-renders the view with the new count.
The private keyword is a convention — @State properties should be owned by the view that declares them and not
accessed from outside.
When to Use @State
Use @State for simple, local values that belong to a single view:
- A boolean for whether a sheet is showing
- A string for a text field’s content
- A number for a counter or slider
struct ToggleExampleView: View {
@State private var isWoodyFavorite = true
var body: some View {
Toggle("Woody is my favorite", isOn: $isWoodyFavorite)
}
}
Notice the $ prefix on $isWoodyFavorite. This creates a binding — a two-way connection that lets the Toggle
both read and write the value. That brings us to the next topic.
Sharing State with @Binding
@Binding lets a child view read and write a state value owned by its parent. The child doesn’t own the data — it
borrows it.
Apple Docs: Binding — SwiftUI
Imagine Remy from Ratatouille controlling Linguini by pulling his hair. Linguini (the child view) doesn’t own the cooking decisions — Remy (the parent view) does. But Linguini can act on them and send feedback back.
struct ParentView: View {
@State private var movieTitle = "Toy Story"
var body: some View {
VStack {
Text("Selected: \(movieTitle)")
MoviePickerView(title: $movieTitle)
}
}
}
struct MoviePickerView: View {
@Binding var title: String
var body: some View {
Button("Switch to Finding Nemo") {
title = "Finding Nemo"
}
}
}
Selected: Toy Story
[Switch to Finding Nemo]
(after tapping)
Selected: Finding Nemo
The parent creates the state with @State and passes a binding using the $ prefix. The child declares its property as
@Binding — no $ on the declaration, no initial value. When the child modifies title, the parent’s state updates,
and both views re-render.
The $ Prefix Explained
The $ prefix is how you get a Binding from a @State property:
movieTitlegives you the currentStringvalue (for reading).$movieTitlegives you aBinding<String>(for reading and writing).
Controls like Toggle, TextField, and Slider all require bindings because they need to both display and modify the
value.
External Data with @Observable
When your data model lives outside the view — like a list of movies or a user profile — you use the @Observable macro.
This replaced the older ObservableObject protocol starting in iOS 17.
Apple Docs: Observable — Observation
@Observable
class MovieLibrary {
var movies = ["Toy Story", "Up", "Coco"]
var favoriteCount = 0
}
struct LibraryView: View {
var library = MovieLibrary()
var body: some View {
VStack {
Text("Movies: \(library.movies.count)")
Text("Favorites: \(library.favoriteCount)")
Button("Add WALL-E") {
library.movies.append("WALL-E")
}
}
}
}
Movies: 3
Favorites: 0
[Add WALL-E]
(after tapping)
Movies: 4
Favorites: 0
The @Observable macro automatically tracks which properties each view reads. When library.movies changes, only views
that read movies re-render. Views that only read favoriteCount stay untouched. This is more efficient than the older
ObservableObject approach, which re-rendered on any property change.
Passing @Observable Objects to Child Views
You pass @Observable objects as regular properties. No special wrappers needed on the child side:
struct MovieCountView: View {
var library: MovieLibrary
var body: some View {
Text("\(library.movies.count) movies")
}
}
If the child needs to modify the object, it just calls methods or sets properties directly. The @Observable macro
handles the change tracking.
Note:
@Observablerequires iOS 17 or later. If you need to support iOS 16, useObservableObjectwith@Publishedproperties instead.
App-Wide Values with @Environment
@Environment lets you read values that SwiftUI provides globally, like the current color scheme (light or dark mode),
the display size class, or the dismiss action for a sheet.
Apple Docs: Environment — SwiftUI
struct ThemeAwareView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
Text("Current mode: \(colorScheme == .dark ? "Dark" : "Light")")
.foregroundStyle(colorScheme == .dark ? .white : .black)
}
}
Current mode: Light
(or "Dark" if the device is in dark mode)
The \.colorScheme is a key path into SwiftUI’s environment. SwiftUI provides dozens of built-in environment
values. Here are some useful ones:
\.colorScheme—.lightor.dark\.dismiss— A function to dismiss the current view (useful in sheets)\.horizontalSizeClass—.compact(iPhone) or.regular(iPad)
Injecting Custom Values
You can inject your own @Observable objects into the environment so any descendant view can access them:
@Observable
class PixarSettings {
var showCredits = true
}
// In the parent view
ContentView()
.environment(PixarSettings())
// In any descendant view
struct CreditsView: View {
@Environment(PixarSettings.self) var settings
var body: some View {
if settings.showCredits {
Text("Directed by Pete Docter")
}
}
}
This is useful for settings, themes, or shared services that many views need but shouldn’t be passed through every intermediate view.
Common Mistakes
Using @State for Shared Data
@State is for data owned by a single view. If two sibling views need the same data, lifting the state to their parent
and using @Binding is the correct approach.
// ❌ Each view has its own separate count
struct ViewA: View {
@State private var count = 0
var body: some View { Text("\(count)") }
}
struct ViewB: View {
@State private var count = 0
var body: some View { Text("\(count)") }
}
// ✅ Parent owns the state, children share it
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
CountDisplay(count: count)
CountButton(count: $count)
}
}
}
Forgetting the $ When Passing Bindings
Controls that modify values need a Binding, not a plain value. If you forget the $, you will get a compile error.
// ❌ Missing $ — this won't compile
TextField("Movie name", text: movieName)
// ✅ Pass a binding with $
TextField("Movie name", text: $movieName)
Making @State Properties Public
@State properties should always be private. They are the view’s internal data. If another view needs to read or
write the value, pass a @Binding instead.
// ❌ @State should not be public
struct BadView: View {
@State var score = 0
var body: some View { Text("\(score)") }
}
// ✅ Keep @State private
struct GoodView: View {
@State private var score = 0
var body: some View { Text("\(score)") }
}
What’s Next?
@Statestores local values that trigger view re-renders when they change.@Bindinglets child views read and write a parent’s state.@Observabletracks changes in external data models efficiently (iOS 17+).@Environmentgives views access to system-wide and custom shared values.- Use
$to create a binding from a@Stateproperty.
With state management in place, your views can respond to user input and update dynamically. Next up: building the most
common iOS pattern — scrollable lists with navigation. Continue to
Lists and Navigation in SwiftUI to learn about List,
NavigationStack, and detail views.