Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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) => void
  • SecureField(placeholder, onChange) — password input, onChange: (value: string) => void
  • Toggle(label, onChange) — boolean toggle, onChange: (value: boolean) => void
  • Slider(min, max, onChange) — numeric slider, onChange: (value: number) => void
  • Picker(onChange) — dropdown, onChange: (index: number) => void; items via pickerAddItem

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: ForEach iterates by index over a numeric state. Keep a count state in sync with your array, then read the items via array.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.

Next Steps

  • Events — Click, hover, keyboard events
  • Widgets — All available widgets
  • Layout — Layout containers