← ConceptsViewModel-owned stateThe Layout has no state. Every mutation — counter increments, form inputs, toggle flips — flows through the ViewModel. Here is why that constraint makes the whole system easier to reason about.~6 min read
The contractFor every piece of mutable state a screen needs, there is exactly one field on the ViewModel. The Layout reads that field through @{binding} and calls event handlers (also on the ViewModel) to propose mutations. The ViewModel decides what to do with each proposal — accept, transform, reject — and publishes the new state atomically. No component anywhere holds state that the ViewModel doesn't know about.
counter-viewmodel.ts
// ✓ Every mutable value is a ViewModel field.export class CounterViewModel extends ViewModel { count = 0; isBusy = false; onIncrement() { this.count += 1; this.notify(); } onReset() { this.count = 0; this.notify(); }} // ✗ NEVER do this inside the Layout JSON or a cell:// stash a local useState in a custom cell React implementation.// The cell loses state on re-render, iOS/Android have no equivalent,// and tests can't reach it. State lives on the VM or it doesn't exist.TestabilityBecause every mutation goes through the ViewModel, unit-testing business logic requires zero rendering. No mounting, no simulator spin-up, no browser. Just instantiate the ViewModel, call methods, assert on the data field. The Layout becomes a pure function of data — and pure functions are boring, which is what you want for UI.
counter.test.ts
// No layout under test. Just the ViewModel.it("decrements when count > 0", () => { const vm = new CounterViewModel(); vm.onAppear(); vm.onIncrement(); vm.onIncrement(); vm.onDecrement(); expect(vm.data.count).toBe(1);});Cross-platform paritySwiftUI, Compose, and React all have their own state primitives — @State, remember { mutableStateOf }, useState. If a JsonUI screen used them, parity would evaporate: what does the iOS-specific animation timing do when the Android side rebinds? By centralizing state in the ViewModel (one class, one contract, rendered the same way by each platform's generated code), we sidestep three whole state models and have one source of truth.
three platforms, one shape
// Same ViewModel API, three platforms. Each subclass uses the// platform-idiomatic notify mechanism, but the public surface is identical. // Swift — @Published triggers SwiftUI re-render.final class CounterViewModel: ObservableObject { @Published var count: Int = 0 func onIncrement() { count += 1 }} // Kotlin — StateFlow triggers Compose re-composition.class CounterViewModel : ViewModel() { private val _count = MutableStateFlow(0) val count: StateFlow<Int> = _count.asStateFlow() fun onIncrement() { _count.update { it + 1 } }} // TypeScript — notify() triggers the React hook to setState.export class CounterViewModel extends ReactViewModel { count = 0; onIncrement() { this.count += 1; this.notify(); }}But what about useState?You still have access to platform-local state inside custom component implementations (TableOfContents uses useState for the observed active anchor; that is hidden inside the React component). The rule applies to screen-level state that the spec describes. If a piece of state is transient, purely visual, and never referenced in the spec's uiVariables, it can live wherever the platform prefers. The boundary is the spec — if it is there, the ViewModel owns it.
Keep reading
Data binding as contractThe typed, one-way binding that connects the Layout to the ViewModel./concepts/data-binding
Hot reload everywhereOne edit, three live reloads. How Dynamic mode works under the hood./concepts/hot-reload