EduFlow

Section 3 · Building Screens with SwiftUI

State, Bindings and @Observable

19 min3 min readSpot the bug

Lesson at a glance

Give each piece of data one source of truth, using @State for view-owned values, @Binding to share write access, and @Observable classes with @Bindable and the environment for app data.

  1. 1@State: data the view owns
  2. 2@Binding: borrowing someone else’s state
  3. 3@Observable: shared app data
  4. 4@Bindable: bindings into an observable object

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

A SwiftUI view is a struct that SwiftUI creates, reads and throws away whenever it likes. So where does “the user switched on the Large Expenses Only filter” live? Not in a normal property: the struct holding it may be gone a moment later, and from inside body you can’t even change it. SwiftUI’s answer is a small set of property wrappers, each answering one question: who owns this data?

@State: data the view owns

ExpensesScreen.swift
import SwiftUI

struct ExpensesScreen: View {
    @State private var showLargeOnly = false

    var body: some View {
        Toggle("Large expenses only", isOn: $showLargeOnly)
    }
}

@State tells SwiftUI to keep the value in storage it manages, tied to this view’s place in the hierarchy. The struct can be recreated a hundred times and the value survives. When the value changes, SwiftUI re-reads body and updates the screen.

The $ in $showLargeOnly asks for a binding: a two-way connection to the state. Toggle doesn’t own the on/off value; it receives a binding so it can read the current value and write the new one when tapped. Every input control (TextField, Toggle, Picker, DatePicker, Slider) works this way.

@Binding: borrowing someone else’s state

Extract the toggle into its own view, and the child needs to change state that the parent owns:

Swift
struct FilterBar: View {
    @Binding var showLargeOnly: Bool

    var body: some View {
        Toggle("Large expenses only", isOn: $showLargeOnly)
    }
}

// In ExpensesScreen's body:
FilterBar(showLargeOnly: $showLargeOnly)

The parent remains the single source of truth; the child gets a binding to it. If the child declared @State instead, it would hold a separate copy that starts with the parent’s value and then drifts apart. That’s the bug behind most “my toggle does nothing” questions.

ExpensesScreen owns showLargeOnly with State. It passes a binding to FilterBar, whose Toggle writes through the binding back to the state. The store, an Observable class owned by the app, is read by both ExpensesScreen and SettingsScreen. ExpensesScreen @State showLargeOnly FilterBar @Binding showLargeOnly $binding writes back @Observable BudgetStore owned once with @State views that read a property update when it changes
One source of truth, shared through bindings

@Observable: shared app data

A filter switch belongs to one screen. The list of expenses belongs to the whole app: the list shows it, the summary totals it, the add form appends to it. That’s shared identity, which the structs lesson said calls for a class. Mark it with the @Observable macro from the Observation framework:

BudgetStore.swift
import Observation
import Foundation

@Observable
final class BudgetStore {
    var expenses: [Expense] = []
    var monthlyLimit: Decimal = 1500

    var totalSpent: Decimal {
        expenses.reduce(0) { $0 + $1.amount }
    }

    var remaining: Decimal {
        monthlyLimit - totalSpent
    }

    func add(_ expense: Expense) {
        expenses.append(expense)
    }
}

No property wrappers on the properties, no publishers. The macro rewrites each stored property so SwiftUI can record which ones a body reads. A view that shows store.remaining reads monthlyLimit and expenses through it, so it updates when either changes; a view that only shows the limit ignores new expenses.

Notice that totalSpent and remaining are computed, not stored. It’s tempting to keep a var total and update it in add, but then every future method that touches expenses (delete, edit, import) must remember to update it too, and one day one won’t. Store the minimum facts and derive everything else. Observation tracks computed properties through the stored ones they read, so the screen stays correct for free.

Create the store once, near the top of the app, and own it with @State:

PocketBudgetApp.swift
@main
struct PocketBudgetApp: App {
    @State private var store = BudgetStore()

    var body: some Scene {
        WindowGroup {
            ExpensesScreen(store: store)   // ExpensesScreen now has `let store: BudgetStore`
                .environment(store)
        }
    }
}

Child views can take the store as a plain let store: BudgetStore property and call store.add(…); no wrapper is needed to read or call methods. For screens deep in the hierarchy, .environment(store) makes it available without threading it through every initializer, and a view picks it up with @Environment(BudgetStore.self) private var store.

@Bindable: bindings into an observable object

A TextField that edits the monthly limit needs a binding to store.monthlyLimit. @Bindable provides it:

Swift
struct SettingsScreen: View {
    @Environment(BudgetStore.self) private var store

    var body: some View {
        @Bindable var store = store
        Form {
            TextField("Monthly limit", value: $store.monthlyLimit, format: .currency(code: "USD"))
                .keyboardType(.decimalPad)
        }
    }
}

When the store arrives as a property, write @Bindable var store: BudgetStore instead; the local form shown here is for objects that come from the environment.

To decide which wrapper to use, ask who owns the data. Does this view own a simple value? @State. Does it need to change a value someone else owns? @Binding. Is it shared app data? An @Observable class, owned once with @State and passed down. The next lesson puts the store to work in a real list with navigation and an add-expense form.

Key takeaways

  • @State stores a value outside the view struct so it survives re-renders; mark it private, because the view owns it.
  • A $ prefix gives you a Binding: read and write access to someone else’s source of truth, which is what controls like Toggle and TextField need.
  • Mark a shared model class with @Observable, and SwiftUI re-renders exactly the views whose body read a property that changed.
  • Own an @Observable object with @State at the top, then pass it down directly or through .environment(_:).
  • Use @Bindable when you need bindings to the properties of an @Observable object, for example to edit them in a form.

Further reading

Check your understanding

4 questions

Question 1 of 4

A view declares var count = 0 and a button runs count += 1. What happens?

Question 2 of 4

A parent owns @State private var showLargeOnly = false. A child FilterBar must flip it. What does the child declare, and what does the parent pass?

Question 3 of 4

Which views re-render when store.monthlyLimit changes on an @Observable store?

Question 4 of 4

A settings screen gets the store with @Environment(BudgetStore.self) private var store and needs $store.monthlyLimit for a TextField. What do you add?

Practice

Spot the bug

The card that won't expand

This card should show the date and category when the user taps More. It doesn’t build: Xcode reports “cannot use mutating member on immutable value: ‘self’ is immutable” on the toggle() call. Select the line you need to change to fix it.

Select the line(s) you think are wrong, then check.