State Management
Perry uses reactive state to automatically update the UI when data changes.
Every snippet below is excerpted from
docs/examples/ui/state/snippets.ts —
CI compiles and runs it on every PR.
Creating State
const counter = State(0) // number state
const username = State("Perry") // string state
const items = State<string[]>([]) // array state
State(initialValue) creates a reactive state container.
Reading and Writing
const value = counter.value // Read current value
counter.set(42) // Set new value → triggers UI update
Every .set() call re-renders the widget tree with the new value.
Reactive Text
Template literals with state.value update automatically:
const showCount = State(0)
const countLabel = Text(`Count: ${showCount.value}`)
// The text updates whenever showCount changes.
This works because Perry detects state.value reads inside template literals
and creates reactive bindings.
Binding Inputs to State
Input widgets expose an onChange callback. Forward that into a state’s
.set(...) to keep the state in sync as the user types/toggles/drags:
const input = State("")
const field = TextField("Type here...", (v: string) => input.set(v))
// Optional: also let input.set("hello") update the field on screen.
stateBindTextfield(input, field)
Input control signatures:
TextField(placeholder, onChange)— text input,onChange: (value: string) => voidSecureField(placeholder, onChange)— password input,onChange: (value: string) => voidToggle(label, onChange)— boolean toggle,onChange: (value: boolean) => voidSlider(min, max, onChange)— numeric slider,onChange: (value: number) => voidPicker(onChange)— dropdown,onChange: (index: number) => void; items viapickerAddItem
For programmatic-to-UI sync (state-drives-widget) use the dedicated binders:
stateBindTextfield, stateBindSlider, stateBindToggle, stateBindTextNumeric,
stateBindVisibility.
onChange Callbacks
Listen for state changes with the free-function stateOnChange:
const watched = State(0)
stateOnChange(watched, (newValue: number) => {
console.log(`Count changed to ${newValue}`)
})
ForEach
Render a list from numeric state (the index count):
const fruits = State(["Apple", "Banana", "Cherry"])
const fruitCount = State(3)
const fruitList = VStack(16, [
ForEach(fruitCount, (i: number) =>
Text(`${i + 1}. ${fruits.value[i]}`),
),
])
Note:
ForEachiterates by index over a numeric state. Keep a count state in sync with your array, then read the items viaarray.value[i]inside the closure.
ForEach re-renders the list when the count state changes:
// Add an item:
fruits.set([...fruits.value, "Date"])
fruitCount.set(fruitCount.value + 1)
// Remove an item:
fruits.set(fruits.value.filter((_, i) => i !== 1))
fruitCount.set(fruitCount.value - 1)
Conditional Rendering
Use state to conditionally show widgets:
const showDetails = State(false)
const detailsLabel: number = showDetails.value
? Text("Details are visible!")
: Spacer()
const detailsPanel = VStack(16, [
Button("Toggle", () => showDetails.set(!showDetails.value)),
detailsLabel,
])
Multi-State Text
Text can depend on multiple state values:
const firstName = State("John")
const lastName = State("Doe")
const greeting = Text(`Hello, ${firstName.value} ${lastName.value}!`)
// Updates when either firstName or lastName changes.
State with Objects and Arrays
const user = State({ name: "Perry", age: 0 })
// Update by replacing the whole object:
user.set({ ...user.value, age: 1 })
const todos = State<{ text: string; done: boolean }[]>([])
// Add a todo:
todos.set([...todos.value, { text: "New task", done: false }])
// Toggle a todo (must produce a new array reference):
const next = todos.value.slice()
if (next.length > 0) {
next[0] = { ...next[0], done: !next[0].done }
todos.set(next)
}
Note: State uses identity comparison. You must create a new array/object reference for changes to be detected. Mutating in-place without calling
.set()with a new reference won’t trigger updates.
Complete Example
// demonstrates: complete reactive todo app combining State, ForEach, and widget tree mutation
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm
import {
App,
Text,
Button,
TextField,
VStack,
HStack,
State,
ForEach,
Spacer,
Divider,
} from "perry/ui"
const todos = State<string[]>([])
const count = State(0)
const input = State("")
App({
title: "Todo App",
width: 480,
height: 600,
body: VStack(16, [
Text("My Todos"),
HStack(8, [
TextField("What needs to be done?", (value: string) => input.set(value)),
Button("Add", () => {
const text = input.value
if (text.length > 0) {
todos.set([...todos.value, text])
count.set(count.value + 1)
input.set("")
}
}),
]),
Divider(),
ForEach(count, (i: number) =>
HStack(8, [
Text(todos.value[i]),
Spacer(),
Button("Delete", () => {
todos.set(todos.value.filter((_, idx) => idx !== i))
count.set(count.value - 1)
}),
]),
),
Spacer(),
Text(`${count.value} items`),
]),
})
This program is built and run by CI (scripts/run_doc_tests.sh), so the
snippet above always matches the compiled artifact under
docs/examples/ui/state/todo_app.ts.