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.

One file. Four primitives.

Save as Sources/CounterApp/CounterApp.swift and run with swift run CounterApp. Tab between the controls; the counter and toggle are independent slots of state.

CounterApp.swift swift
import TerminalUI
import TerminalUICLI
import Observation

@main
struct CounterApp: App {
  var body: some Scene {
    WindowGroup("Counter") {
      RootView()
    }
  }
}

// 1. Local view state — owned by the view, scoped to its identity.
struct RootView: View {
  @State private var counter = 0

  var body: some View {
    VStack(alignment: .leading, spacing: 1) {
      Text("Count: \(counter)").bold()
      // 2. Pass a binding down so children can mutate the parent's state.
      CounterControls(value: $counter)
      Divider()
      // 3. Cross-cutting state lives in an @Observable model.
      ObservableExample()
    }
    .padding(.init(horizontal: 1, vertical: 0))
  }
}

struct CounterControls: View {
  // 4. @Binding is a read-write window into someone else's @State.
  @Binding var value: Int

  var body: some View {
    HStack(spacing: 1) {
      Button("-") { value -= 1 }
      Stepper("Value", value: $value, in: 0...10)
      Button("+") { value += 1 }
    }
  }
}

// 5. Shared editable model — class, @Observable, identity outside any view.
@Observable
final class Settings {
  var theme = "dark"
  var verbose = false
}

struct ObservableExample: View {
  // 6. Construct or inherit the model. @State holds the reference.
  @State private var settings = Settings()

  var body: some View {
    VStack(alignment: .leading, spacing: 1) {
      Text("Theme: \(settings.theme)")
      // 7. @Bindable produces bindings for properties of the model.
      @Bindable var bindable = settings
      Toggle("Verbose", isOn: $bindable.verbose)
    }
  }
}

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