LearnManaging state
Managing state
As your app grows, it pays to be deliberate about where state lives, what shape it has, and how it flows between components. Redundant or duplicated state is a common source of bugs. This chapter shows how to structure state well, how to keep update logic easy to maintain, and how to share state between components that are close together, far apart, or in different tabs.
If you know React's "Managing State" chapter, much of this will be familiar: the data advice is the same. What differs is that weft components run once, so state is never "preserved between renders" or "reset by position". It lives with the component that made it, and a box tells exactly the places that read it when it changes.
Reacting to input with state
You don't change the page step by step. You describe what it shows in each state, and handlers change only the state. This quiz has one status box, and every screen comes from it:
import { derived, state } from '@weft-js/core'import { Alert, Button, Card, Textarea } from '@weft-js/warp'type Status = 'typing' | 'submitting' | 'success'// Pretends to ask a server: it waits, then accepts "lima" and refuses anything else.const check = (answer: string) => new Promise<void>((resolve, reject) => { const right = answer.trim().toLowerCase() === 'lima' setTimeout(() => (right ? resolve() : reject(new Error('Good guess, but no. Try again.'))), 800) })export default function Quiz() { const answer = state('') const status = state<Status>('typing') const error = state<string | null>(null) const empty = derived(() => answer.value.trim() === '') const submit = async (e: SubmitEvent) => { e.preventDefault() status.value = 'submitting' try { await check(answer.value) status.value = 'success' } catch (err) { status.value = 'typing' error.value = err instanceof Error ? err.message : String(err) } } return ( <Card title="City quiz"> {status.value === 'success' ? ( <Alert tone="positive" title="That's right!">Lima is the capital of Peru.</Alert> ) : ( <form onSubmit={e => void submit(e)}> <p>Which city is the capital of Peru?</p> <Textarea label="Your answer" value={answer.value} disabled={status.value === 'submitting'} error={error.value} onInput={e => { answer.value = e.currentTarget.value error.value = null }} /> <Button type="submit" loading={status.value === 'submitting'} disabled={empty.value}> Submit </Button> </form> )} </Card> )}Try this: Submit a wrong answer, then start typing: the error goes. Then answer Lima. Every screen comes from one status box.
Read Reacting to input with state to learn how to list the visual states of a component, model them with one status box, and why weft branches in JSX instead of returning early.
Choosing the state structure
Good state is small and can't contradict itself. Two booleans that must never both be true are one status; a selected item is an id, not a copy:
import { derived, state } from '@weft-js/core'type Snack = { id: number; title: string }export function Menu(props: { snacks: Snack[] }) { const chosenId = state<number | null>(null) const chosen = derived(() => props.snacks.find(s => s.id === chosenId.value)) return ( <section> {props.snacks.map(s => ( <button key={s.id} type="button" onClick={() => (chosenId.value = s.id)}>{s.title}</button> ))} <p>{chosen.value ? `You picked ${chosen.value.title}` : 'Pick a snack'}</p> </section> )}Read Choosing the state structure to learn when to use one box or several, why you never copy a prop into state, and how to flatten nested data.
Derived values
Anything you can work out from other values is derived. You say how once, and it can't go stale:
1 × $12 · shipping $4
Total $16
import { derived, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Basket() { const price = 12 const qty = state(1) const shipping = derived(() => (qty.value >= 3 ? 0 : 4)) const total = derived(() => price * qty.value + shipping.value) return ( <Card title="Your basket"> <p>{qty.value} × $12 · shipping ${shipping.value}</p> <p><strong>Total ${total.value}</strong></p> <Button onClick={() => qty.value++}>Add one</Button> <Button emphasis="ghost" tone="neutral" onClick={() => (qty.value = 1)} > Reset </Button> </Card> )}Try this: Press Add one twice. At three items shipping drops to $0 and the total changes with it.
Read Derived values to learn when a derived value runs, how chains of them stay consistent, and why you never keep a copy in sync with watch.
Sharing state between components
To make two components change together, move their state to their closest common parent and pass it down. Only one of these panels can be open, because the parent holds which:
Lisbon is Portugal's capital, built on seven hills above the Tagus estuary.
import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'function Panel(props: { title: string; active: boolean; onShow: () => void; children?: unknown }) { return ( <Card title={props.title}> {props.active ? ( <p>{props.children}</p> ) : ( <Button size="sm" emphasis="ghost" onClick={props.onShow}>Show</Button> )} </Card> )}export default function Lisbon() { // The parent owns which panel is open, so at most one ever is. const open = state(0) return ( <> <Panel title="About" active={open.value === 0} onShow={() => (open.value = 0)}> Lisbon is Portugal's capital, built on seven hills above the Tagus estuary. </Panel> <Panel title="Getting around" active={open.value === 1} onShow={() => (open.value = 1)}> Old yellow trams climb streets too steep for buses. </Panel> </> )}Try this: Open the second panel. The first closes, because the parent holds which one is open.
Read Sharing state between components to learn about lifting state up, bind:, passing a box, and how to reset state when the subject changes.
Passing data deeply
A context gives a value to everything built inside a part of the tree, without passing it through each layer. The button below reads a box that a component two levels up provided:
import { context, state, type State } from '@weft-js/core'import { Button, Card, Row, Switch } from '@weft-js/warp'// A box, so a change reaches every reader. The fallback serves anything outside a provider.const Armed = context<State<boolean>>(state(false))function DeleteButton() { const armed = Armed.use return ( <Button tone={armed.value ? 'critical' : 'neutral'} disabled={!armed.value}> Delete everything </Button> )}// A middle layer that passes nothing down.function Actions() { return ( <Row gap="tight"> <Button emphasis="outline">Export</Button> <DeleteButton /> </Row> )}export default function DangerZone() { const armed = state(false) return Armed.provide(armed, () => ( <Card title="Danger zone"> <Switch checked={armed.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (armed.value = e.currentTarget.checked)} > Allow deleting </Switch> <Actions /> </Card> ))}Try this: Turn on the switch. The button two components down follows, and Actions in between passes nothing.
Read Passing data deeply to learn how context, provide and use work, where to read a context, and when a module box or defineState is the better tool.
Keyed state and entries
A row can read one entry of an object box with .get(key). Then only a change to that entry re-runs it, however long the list:
apple: 3
pear: 0
plum: 7
import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'const FRUIT = ['apple', 'pear', 'plum']export default function Stock() { const stock = state<Record<string, number>>({ apple: 3, pear: 0, plum: 7 }) const restock = (fruit: string) => stock.set(d => { d[fruit] = (d[fruit] ?? 0) + 1 }) return ( <Card title="Stock"> {FRUIT.map(f => ( <p key={f}> {f}: {stock.get(f)}{' '} <Button size="sm" emphasis="ghost" onClick={() => restock(f)}>+1</Button> </p> ))} </Card> )}Try this: Press +1 on a row. Only that row's number changes.
Read Keyed state and entries to learn how get and set work, how to pass a box to row components, and how long a row's own state lasts.
Hover, focus and shared UI state
interaction() turns hover, focus and press into boxes, and a share key makes one row shared between components, tabs and people:
Right now it is idle.
import { derived } from '@weft-js/core'import { interaction } from '@weft-js/core/interaction'import { Button, Card } from '@weft-js/warp'export default function Pointer() { // No `share`: this row is private to this one button. const row = interaction() const words = derived(() => { const on = [row.hover.value && 'hovered', row.focus.value && 'focused', row.pressed.value && 'pressed'] return on.filter(Boolean).join(', ') || 'idle' }) return ( <Card title="One button, three bits"> <Button ui={row}>Press me</Button> <p>Right now it is {words.value}.</p> </Card> )}Try this: Hover the button, press Tab to focus it, then hold it down. Each bit is a box you can read.
Read Hover, focus and shared UI state to learn how warp draws a row, how far sharing reaches, and how to keep other UI state the same in every tab.
App-wide state with defineState
defineState is a named scope of state that any component imports. Its actions keep update logic in one place, and it can persist, sync across tabs and undo:
import { defineState, derived, state } from '@weft-js/core'import { Button, Card, Row, Tag } from '@weft-js/warp'const CITIES = ['Lisbon', 'Oslo', 'Osaka', 'Lima']// Every change to `pins` is in here. Components only read the scope and call its actions.const board = defineState('learn-board', () => { const pins = state<string[]>([]) const count = derived(() => pins.value.length) const pin = (city: string) => { if (!pins.value.includes(city)) pins.value = [...pins.value, city] } const unpin = (city: string) => (pins.value = pins.value.filter(c => c !== city)) const clear = () => (pins.value = []) return { pins, count, pin, unpin, clear }}, { history: { limit: 50 } })export default function Board() { const history = board.$history return ( <Card title={`Pinned: ${board.count.value}`}> <Row gap="tight" wrap> {CITIES.map(c => <Button key={c} size="sm" emphasis="soft" onClick={() => board.pin(c)}>{c}</Button>)} </Row> <Row gap="tight" wrap> {board.pins.value.map(c => <Tag key={c} label={c} onRemove={() => board.unpin(c)}>{c}</Tag>)} </Row> <Row gap="tight"> <Button size="sm" emphasis="outline" disabled={!history.canUndo.value} onClick={() => history.undo()}>Undo</Button> <Button size="sm" emphasis="outline" disabled={!history.canRedo.value} onClick={() => history.redo()}>Redo</Button> <Button size="sm" emphasis="ghost" disabled={board.count.value === 0} onClick={() => board.clear()}>Clear</Button> </Row> </Card> )}Try this: Pin three cities, remove one, then press Undo twice. Each action is one step.
Read App-wide state with defineState to learn how to define and use a scope, add history and persistence, and how scopes behave on the server.
Recap
- Describe states, not steps, and keep each piece of state small and free of contradictions.
- Derive what can be computed.
- Lift state to the closest common parent; use context for values that depend on where you are in the tree.
- Read one entry per row with
.get; useinteraction()for hover and focus. - Put app-wide state in a
defineStatescope.
What's next
Head to Reacting to input with state to start the chapter page by page.