Learn · 02 of 09
State and bindings.
TerminalUI's state model is the SwiftUI one. Four primitives cover
almost every authored case:
@State — local, view-owned, identity-keyed. @Binding — a read-write window into someone else's
@State. @Observable — for editable models that outlive a
single view. @Bindable — produces typed bindings out of an
@Observable model. Repo-owned, on the same
invalidation path as @State.
This tutorial walks through all four in one app. The full code
compiles against the TerminalUI +
TerminalUICLI products documented on
Start.
Anatomy
1 — @State
Local, identity-keyed storage. The runtime allocates a slot keyed by
the view's identity path plus the source location of the
@State declaration. Two views with the same body in
different positions get different slots; the same view in the same
position keeps its slot across rerenders.
Always declare @State as
private. The wrapper is owned by the view, not
the caller. Marking it private prevents accidental external reads
that would defeat the keying invariant.
2 — @Binding
A typed read-write reference to another view's @State.
You construct one with the $ projection
($counter) and pass it down. The child mutates as if it
owned the value, the parent stays the source of truth, and
invalidation routes through the parent's slot.
Bindings can also be created manually with
Binding.init(get:set:) when bridging to an external
model. Action-bearing closures stay @MainActor so the
mutation happens on the same actor as View.body.
3 — @Observable
For models that need to outlive a single view, persist across
navigation, or be shared by a subtree. TerminalUI uses Swift's
standard @Observable macro from the
Observation module — a class with auto-tracked
properties. Reads from a view's body or
EnvironmentReader register a dependency; mutations
invalidate exactly the views that read.
4 — @Bindable
Produces bindings for any property of an @Observable
model. TerminalUI ships its own @Bindable (rather than
reusing SwiftUI's) so observable editing flows through the same
invalidation path as @State — a deliberate deviation
documented in
Principles.
Use it as a local redeclaration:
@Bindable var bindable = settings
Toggle("Verbose", isOn: $bindable.verbose)
Why state survives rerenders
@State persistence is keyed by identity-path plus source
location. As long as the view's identity is stable across
rerenders, the state slot survives. Reordering, layout changes,
focus changes, and scroll position changes don't count as identity
transitions; insertion, deletion, or
id(…)-driven re-keying do.
One consequence worth knowing: state owned by an active-tab
body or a deferred presentation can be intentionally ephemeral
across tab switches and presentation churn. If the state must
survive that, hoist ownership above the lazy seam and thread it
down through bindings.
Where to go next