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@State: data the view owns
- 2@Binding: borrowing someone else’s state
- 3@Observable: shared app data
- 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
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:
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.
@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:
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:
@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:
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
@Statestores a value outside the view struct so it survives re-renders; mark itprivate, because the view owns it.- A
$prefix gives you aBinding: read and write access to someone else’s source of truth, which is what controls likeToggleandTextFieldneed. - Mark a shared model class with
@Observable, and SwiftUI re-renders exactly the views whosebodyread a property that changed. - Own an
@Observableobject with@Stateat the top, then pass it down directly or through.environment(_:). - Use
@Bindablewhen you need bindings to the properties of an@Observableobject, for example to edit them in a form.