LearnManaging state
App-wide state with defineState
Some state belongs to the whole app: the basket that the header counts and the checkout lists, the signed-in user's preferences, the document an editor has open. Passing it down from the root would thread it through every layer, and a context is built for values that differ by subtree, not one value for everyone. defineState is weft's tool for this: a named scope of boxes, derived values and actions that any component imports.
Basket 0
Nothing yet
import { defineState, derived, state } from '@weft-js/core'import { Badge, Button, Card } from '@weft-js/warp'// One scope of shared state. Any component can import it; it is set up the first time something reads it.const basket = defineState('learn-basket', () => { const items = state<string[]>([]) const count = derived(() => items.value.length) return { items, count, add: (name: string) => (items.value = [...items.value, name]) }})function Header() { return <p>Basket <Badge count>{basket.count.value}</Badge></p>}function Shelf() { return ( <p> <Button size="sm" onClick={() => basket.add('Tea')}>Add tea</Button> <Button size="sm" emphasis="outline" onClick={() => basket.add('Cake')}>Add cake</Button> </p> )}export default function Shop() { return ( <Card title="Two components, one basket"> <Header /> <Shelf /> <p>{basket.items.value.join(', ') || 'Nothing yet'}</p> </Card> )}Try this: Add tea and cake. The badge in Header counts what Shelf added.
Defining a scope
// src/state/basket.tsimport { defineState, derived, state } from '@weft-js/core'export const basket = defineState('basket', () => { const items = state<string[]>([]) const count = derived(() => items.value.length) const add = (name: string) => (items.value = [...items.value, name]) const clear = () => (items.value = []) return { items, count, add, clear }})defineState(name, setup) takes:
- A name, unique in the app. It names the scope in the dev tools, in stored data and in errors. Two scopes with one name throw S009 in development.
- A setup function that makes ordinary boxes and derived values, and returns them along with functions that change them: the scope's actions.
The setup doesn't run when the module loads. It runs once per app, the first time something reads the scope. A scope no page uses costs nothing.
Using it from components
Import the scope and read it like local state:
import { basket } from '../state/basket'export function Header() { return <p>Basket ({basket.count.value})</p>}export function Shelf() { return ( <> <button type="button" onClick={() => basket.add('Tea')}>Add tea</button> <button type="button" onClick={() => basket.add('Cake')}>Add cake</button> </> )}basket.count.value is a read like any other: the text in Header updates when count changes, and nothing else in Header runs. There are no selectors, no connect, no provider at the root of the app. A component that reads one value of a big scope follows only that value.
The handle also has a few $ members of its own, which is why returned names can't start with $ (S009):
| Member | Does |
|---|---|
basket.$name | the scope's name |
basket.$reset() | every box back to its initial value; persisted copies rewritten; history cleared |
basket.$snapshot() | a frozen plain copy of the returned boxes and derived values, for logs and bug reports |
basket.$history | undo and redo, when the scope has history (below) |
Keeping the update logic in one place
React has useReducer for moving update logic out of event handlers into one function. weft has no reducer hook, and doesn't need one. A scope's actions are that place: every way the state can change is a named function next to the state, and components only call them.
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.
// src/state/board.tsimport { defineState, derived, state } from '@weft-js/core'export const board = defineState('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 } })Read the setup and you know everything that can happen to pins. A component says what happened (board.pin('Oslo')), not how the array changes. That's the benefit React's reducers give you, with ordinary functions: TypeScript checks each action's arguments, and there's no action-type string to misspell.
If you like reducers
Nothing stops you from writing one. A reducer is a plain function from state and an action to the next state, and it works with any box:
// src/state/tasks.tsexport type Task = { id: number; text: string; done: boolean }export type TaskAction = | { type: 'added'; id: number; text: string } | { type: 'changed'; task: Task } | { type: 'deleted'; id: number }export function tasksReducer(tasks: Task[], action: TaskAction): Task[] { switch (action.type) { case 'added': return [...tasks, { id: action.id, text: action.text, done: false }] case 'changed': return tasks.map(t => (t.id === action.task.id ? action.task : t)) case 'deleted': return tasks.filter(t => t.id !== action.id) }}import { state } from '@weft-js/core'import { tasksReducer, type Task, type TaskAction } from '../state/tasks'export function TaskApp() { const tasks = state<Task[]>([]) const dispatch = (action: TaskAction) => (tasks.value = tasksReducer(tasks.value, action)) let nextId = 0 return ( <section> <button type="button" onClick={() => dispatch({ type: 'added', id: nextId++, text: 'New task' })}>Add</button> <ul> {tasks.value.map(t => ( <li key={t.id}> <label> <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'changed', task: { ...t, done: !t.done } })} /> {t.text} </label> <button type="button" onClick={() => dispatch({ type: 'deleted', id: t.id })}>Delete</button> </li> ))} </ul> </section> )}dispatch is one line. The reducer is easy to unit test, since it's pure. Use this shape if your team already thinks in actions, or for state with many kinds of change that you want to log or replay. For most state, named actions in a scope are shorter and say the same thing.
Deep diveCompared with Redux, Zustand and Context with a reducer
- Redux keeps app state in one store, changed by dispatching actions to reducers, and read with selectors. A weft app usually has several small scopes instead of one store. Actions are functions on the scope. Selectors aren't needed: reads are already fine-grained, so a component that reads
basket.count.valuefollowscountand nothing else. Undo, which Redux apps add with a library, is thehistoryoption. - Zustand is the closest in spirit: a
createcall that returns state and functions, imported where it's used.defineStateis that, with boxes instead of asetfunction, and with persistence, tab sync and undo as options rather than middleware. - Context with
useReduceris React's built-in answer, and it re-renders every consumer when the value changes. weft splits the two jobs: