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?

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:

  1. @State private var count = 0 creates a state variable with an initial value of 0.
  2. Text("Pixar movies watched: \(count)") reads that value and displays it.
  3. When the button is tapped, count += 1 changes the state.
  4. 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:

  • movieTitle gives you the current String value (for reading).
  • $movieTitle gives you a Binding<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: @Observable requires iOS 17 or later. If you need to support iOS 16, use ObservableObject with @Published properties 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.light or .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?

  • @State stores local values that trigger view re-renders when they change.
  • @Binding lets child views read and write a parent’s state.
  • @Observable tracks changes in external data models efficiently (iOS 17+).
  • @Environment gives views access to system-wide and custom shared values.
  • Use $ to create a binding from a @State property.

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.