Views are a function of state
Think of a stadium scoreboard. Nobody repaints the numbers by hand when a team scores. Someone updates the score, and the board shows the new score automatically.
SwiftUI works the same way. You don’t change labels or buttons directly. You change the state (the data), and SwiftUI calls your view’s body again to draw the new version. Your job is to put each piece of state in the right place and mark it with the right property wrapper, so SwiftUI knows to watch it.
A SwiftUI View is a cheap value that describes UI; SwiftUI recreates these structs constantly. The storage behind @State doesn’t live in the struct: it lives in SwiftUI’s view graph and is tied to the view’s identity (its position in the tree, or an explicit .id). With the Observation framework (iOS 17+), SwiftUI records which properties each body reads and invalidates only the views that read a property that changed.
Nearly every state bug is one of two things: state stored somewhere that doesn’t survive a struct recreation, or identity changing when you didn’t expect it to.
@State: state a view owns
struct CounterView: View {
@State private var count = 0
var body: some View {
Button("Tapped \(count) times") {
count += 1
}
}
}@State tells SwiftUI: “keep this value for me, and redraw the view when it changes”. Mark it private: it belongs to this view, and nobody outside should set it.
@Binding: let a child change the parent’s state
struct SettingsView: View {
@State private var notificationsOn = true
var body: some View {
Form {
NotificationToggle(isOn: $notificationsOn)
Text(notificationsOn ? "You'll get alerts" : "Alerts are off")
}
}
}
struct NotificationToggle: View {
@Binding var isOn: Bool
var body: some View {
Toggle("Notifications", isOn: $isOn)
}
}The $ in $notificationsOn creates a binding: a two-way connection to the parent’s value. The child can read and write it, but the parent still owns it. There’s still only one copy of the truth, which is what makes the Text below update when you flip the toggle.
Predict the output
The user taps Rename. What does the screen show?
struct ParentView: View {
@State private var name = "Ana"
var body: some View {
VStack {
GreetingView(name: name)
Button("Rename") { name = "Marko" }
}
}
}
struct GreetingView: View {
@State var name: String
var body: some View {
Text("Hi, \(name)")
}
}@State uses its initial value only the first time a view with that identity appears. When the parent redraws and passes Marko, the child keeps its own copy, Ana. A child that only displays a value should take it as a plain let name: String; one that edits it should take a @Binding.
Don’t store what you can compute
A common beginner habit is to give every number on screen its own @State. Then the values must be kept in sync by hand, and sooner or later they aren’t:
// Fragile: total is a second copy that you must remember to update
struct CartSummaryFragile: View {
@State private var prices: [Double] = [4.5, 2.0]
@State private var total = 6.5
var body: some View {
VStack {
Text("Total: \(total, format: .currency(code: "EUR"))")
Button("Add coffee") {
prices.append(3.0)
// forgot: total += 3.0
}
}
}
}
// Simple: total is computed from prices every time body runs
struct CartSummary: View {
@State private var prices: [Double] = [4.5, 2.0]
private var total: Double { prices.reduce(0, +) }
var body: some View {
VStack {
Text("Total: \(total, format: .currency(code: "EUR"))")
Button("Add coffee") { prices.append(3.0) }
}
}
}Ask of every value: “can I calculate this from other state?” If yes, make it a computed property. Only the things the user actually changes (here, the list of prices) need to be state. Computing a total on every redraw is cheap; a screen that shows the wrong total is not.
Predict the output
The user taps + three times, then turns Show counter off and on again. What does the counter show?
struct DetailsScreen: View {
@State private var showCounter = true
var body: some View {
Form {
Toggle("Show counter", isOn: $showCounter)
if showCounter {
CounterView()
}
}
}
}
struct CounterView: View {
@State private var count = 0
var body: some View {
Stepper("Count: \(count)", value: $count)
}
}When the toggle is off, the if removes CounterView from the screen, and SwiftUI throws its @State away with it. Turning it on creates a brand-new CounterView that starts at 0. If the count must survive, move the state up into DetailsScreen and pass it down as a @Binding.
Identity decides lifetime
@State lives exactly as long as the view’s identity:
- Structural identity: the position in the view tree. The two branches of an
if/elseare different views, so switching branches resets their state. Prefer modifiers (.opacity,.disabled) over branches when state must survive. - Explicit identity:
.id(value)andForEachids. Changing the id destroys and recreates the view, which is a handy way to reset a form, and a nasty bug when ids aren’t stable (for exampleid: \.selfon non-unique values, or a newUUID()on every load). AnyViewerases the type and makes diffing harder; prefer@ViewBuilderandsome View.
@Statestorage lives in the view graph, tied to identity:if/elsebranches and a changed.idreset it, and later initial values from a parent are ignored.- A child that edits takes a
@Binding; one that only displays takes alet. Derive values you can compute instead of storing them.
Check this part
A child view needs to switch a setting that its parent owns. What should the child use?
A screen shows a list of prices and their total. Which of them should be state?
Why does @State var name: String in a child ignore new values from its parent?
A form view gets .id(selectedUser.id). The user picks another user. What happens to the form's @State?