LearnAdding interactivity
State: a box with .value
Components often need to remember things. A product card remembers how many are in the cart; a gallery remembers which picture it's on; a form remembers what you typed. In weft that memory is state, and a piece of state is a box: state(0) makes a box holding 0, and you read and write it through .value.
Cart 0
import { state } from '@weft-js/core'import { Badge, Button, Card } from '@weft-js/warp'export default function Product() { const inCart = state(0) return ( <Card eyebrow="Coffee" title="House blend, 250 g"> <p>Cart <Badge count>{inCart.value}</Badge></p> <Button onClick={() => inCart.value++}>Add to cart</Button> </Card> )}Try this: Press Add to cart and watch the badge count up.
When a plain variable isn't enough
Here's a gallery that should move to the next picture when you press Next. It keeps its position in a let:
<!-- example: skip deliberately broken: W041 is the point of the example -->
import { Button } from '@weft-js/warp'const pictures = ['Homenaje a la Neurocirugía', 'Floralis Genérica', 'Eternal Presence']export function Gallery() { let index = 0 return ( <section> <h2>{pictures[index]}</h2> <Button onClick={() => index++}>Next</Button> </section> )}Pressing Next changes index, but the heading never moves. The component body ran once, built the heading from index as it was then, and finished. Nothing reads index again, and nothing knows it changed.
The fix is to make it state. The catalog names a let that a handler changes and JSX reads W041, but this weft doesn't report it yet, so watch for it yourself. (A let that names a prop is different, and allowed: see Passing props.)
To update the page, a value needs two things:
- It has to keep its value for as long as the component is on the page.
- Changing it has to reach the parts of the page that show it.
A box does both.
Adding a box
Import state from @weft-js/core and make a box in the component body:
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'const pictures = ['Homenaje a la Neurocirugía', 'Floralis Genérica', 'Eternal Presence']export function Gallery() { const index = state(0) return ( <section> <h2>{pictures[index.value]}</h2> <Button onClick={() => (index.value = (index.value + 1) % pictures.length)}>Next</Button> </section> )}Three changes: state(0) makes the box, the heading reads index.value, and the handler writes index.value. Now the heading follows:
by Marta Colvin Andrade
import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'const sculptures = [ { name: 'Homenaje a la Neurocirugía', artist: 'Marta Colvin Andrade', about: 'Two hands holding a brain, carved in stone.' }, { name: 'Floralis Genérica', artist: 'Eduardo Catalano', about: 'A steel flower whose petals close at night.' }, { name: 'Eternal Presence', artist: 'John Woodrow Wilson', about: 'A bronze head, seven feet tall.' },]export default function Gallery() { // Two boxes, because they change for different reasons. const index = state(0) const showMore = state(false) const next = () => (index.value = (index.value + 1) % sculptures.length) return ( <Card eyebrow={`${index.value + 1} of ${sculptures.length}`} title={sculptures[index.value]?.name}> <p>by {sculptures[index.value]?.artist}</p> <Button size="sm" onClick={next}>Next</Button> <Button size="sm" emphasis="ghost" onClick={() => (showMore.value = !showMore.value)}> {showMore.value ? 'Hide' : 'Show'} details </Button> {showMore.value && <p>{sculptures[index.value]?.about}</p>} </Card> )}Try this: Press Next a few times, then Show details. Each box changes on its own, and the other keeps its value.
Meet your first box
state(initial) returns an object of type State<T> with a value property. That's all a box is, from the outside:
const index = state(0)index.value // read: 0index.value = 1 // writeindex.value++ // read, then write: 2.value is how weft knows who reads what. When JSX reads index.value, that spot in the page subscribes to the box. When a handler writes it, the box tells exactly those spots, and they update. Gallery doesn't run again; nothing else on the page does either.
index itself is const, and stays the same box for the life of the component. Only what's inside changes. That's why the box can keep its value: the body ran once, but the box it made lives on in every closure and binding that holds it.
The type comes from the initial value. When it can't, say it:
import { state } from '@weft-js/core'type Draft = { text: string; at: number }export function makeBoxes() { const name = state('') // State<string> const picked = state<string | null>(null) // without <…> this would be State<null> const drafts = state<Draft[]>([]) // without <…> this would be State<never[]> return { name, picked, drafts }}Deep diveWhy a box, and not a variable or a setter?
A plain variable can't tell anyone it changed: JavaScript has no hook for "this let was reassigned". React solves this by running your whole component again after each change and comparing the result. weft doesn't re-run components, so it needs the reads and writes themselves to be visible, and a property access is the cheapest place to see them. Reading .value registers the reader; writing .value tells the readers.
React's useState gives you a value and a setter:
const [index, setIndex] = useState(0) // ReactsetIndex(index + 1)const index = state(0) // weftindex.value = index.value + 1The differences follow from that:
- One name, not two. You read and write through the same box.
- No snapshot.
index.valueright after a write is the new value. In React,indexstays the old value until the next render. How an update reaches the DOM shows what this means in practice. - No hook rules.
useStatemust be called in the same order on every render, so never in a condition or a loop. A weft component runs once, so there's no order to keep: a box is an object you make like any other. - Nothing re-renders. A write updates the spots that read the box, not the component.
If you have used signals in Solid, Preact or Vue, a box is the same idea, with .value where Solid uses a call.
Writing the same value does nothing
A write compares the new value with the old one using Object.is. If they're the same, nothing happens: no reader runs, nothing on the page is touched.
const size = state('small')size.value = 'small' // same string: nothing happenssize.value = 'large' // different: readers updateFor numbers and strings that's what you'd expect. For objects and arrays it means you must give the box a new object for a change to count. Changing a property inside the object the box already holds is invisible to it. That's the subject of Updating objects and arrays.
Giving a component several boxes
A component can have as many boxes as it needs. The gallery above has two: index for which picture, and showMore for whether the details are open:
const index = state(0)const showMore = state(false)They change for different reasons, so they're separate. Pressing Next writes index; only what reads index updates. Pressing Show details writes showMore; only the details update.
When two values always change together, one box holding an object can be clearer:
const position = state({ x: 0, y: 0 }) // a pointer position: x and y move togetherChoosing the state structure has the guidelines. And a value you can work out from other boxes isn't state at all: make it