Skip to content
weftGitHub

LearnAdding interactivity

Adding interactivity

Some things on the screen change in response to what people do. Typing in a search box filters a list; pressing a button adds to a cart; picking a picture in a gallery shows it. In weft, data that changes over time is called state. Any component can hold it in a box, and handlers change it. This chapter covers both, and what happens between a change and the page showing it.

If you know React, most of this will look familiar, with one difference that runs through every page: a weft component runs once. There is no re-render. Each spot on the page that reads a box updates when that box changes, and nothing else does.

Responding to events

You add interactivity by passing functions to on attributes such as onClick, onInput and onKeyDown. A function like that is an event handler. It runs when the event happens, and usually changes some state:

Notes

0 characters

    import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Notes() {  const draft = state('')  const notes = state<{ id: number; text: string }[]>([])  const add = () => {    const text = draft.value.trim()    if (!text) return    notes.value = [...notes.value, { id: notes.value.length, text }]    draft.value = ''  }  return (    <Card title="Notes">      <input        aria-label="New note"        placeholder="Type, then press Enter"        value={draft.value}        onInput={e => (draft.value = e.currentTarget.value)}        onKeyDown={e => { if (e.key === 'Enter') add() }}      />      <p>{draft.value.length} characters</p>      <Button onClick={add}>Add note</Button>      <ul>{notes.value.map(n => <li key={n.id}>{n.text}</li>)}</ul>    </Card>  )}

    Try this: Type a few words, press Enter, then add another with the button.

    Components can take handlers as props too, so a Toolbar can draw the buttons while its parent decides what each one does. Events bubble up through the page, and a handler can stop that with e.stopPropagation(), or stop the browser's own action with e.preventDefault().

    Read Responding to events to learn how to write handlers, pass them as props, and control how events travel.

    State: a box with .value

    A component that should remember something keeps it in a box. state(0) makes one; you read and write it through .value, in handlers and in JSX alike:

    tsx
    import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Like() {  const likes = state(0)  return <Button onClick={() => likes.value++}>♥ {likes.value}</Button>}

    A plain let wouldn't work: the component ran once, so nothing would read it again. A box is different, because reading .value subscribes and writing it tells the readers. A component can keep several boxes, and each copy of a component has its own:

    1 of 3Homenaje a la Neurocirugía

    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.

    Read State: a box with .value to learn why state is a box, how it compares with React's useState, and why each instance has its own.

    How an update reaches the DOM

    When a handler writes a box, the box changes at once, and the bits of the page that read it are queued. After the handler, the queue runs once and each binding writes the one DOM node it owns. Your component isn't called again.

    So, unlike React's state, there is no snapshot. A read after a write sees the new value, and three writes in a row add up:

    Three writes in a row

    Score 0

    import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Three() {  const score = state(0)  const note = state('')  const addThree = () => {    // Each line reads the value the line before it wrote: there is no snapshot.    score.value = score.value + 1    score.value = score.value + 1    score.value = score.value + 1    note.value = `The handler saw ${score.value} at its end.`  }  return (    <Card title="Three writes in a row">      <p><strong>Score {score.value}</strong></p>      <Button size="sm" onClick={addThree}>+3</Button>      <Button size="sm" emphasis="ghost" onClick={() => (score.value = 0)}>Reset</Button>      <p>{note.value}</p>    </Card>  )}

    Try this: Press +3. Each write reads the one before it, so the score goes up by three, not one.

    Read How an update reaches the DOM to learn what the compiler makes of your JSX, when the page catches up with your boxes, and what flush(), batch() and untrack() are for.

    Updating objects and arrays

    A box compares a new value with the old one using Object.is. Changing a property of the object a box holds doesn't write the box, so nothing updates. Treat objects and arrays in boxes as read-only: make a new one, or let set copy it for you.

    tsx
    import { state } from '@weft-js/core'export function makeTodos() {  const todos = state([{ id: 0, title: 'Buy milk', done: false }])  const add = (title: string) => (todos.value = [...todos.value, { id: todos.value.length, title, done: false }])  const toggle = (id: number) => (todos.value = todos.value.map(t => (t.id === id ? { ...t, done: !t.done } : t)))  const rename = (title: string) => todos.set(d => { d[0] = { ...d[0]!, title } })  return { todos, add, toggle, rename }}

    Read Updating objects and arrays to learn how to add, remove and change items, update nested objects, and what set does and doesn't copy.

    Writing to a parent with bind:

    A child can change its own props, but those changes stay in the child. When the parent wants them, it says so where it uses the child, with bind::

    tsx
    import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'function Stepper(props: { value?: number }) {  let { value = 0 } = props  return <Button onClick={() => value++}>{value}</Button>}export function Booking() {  const guests = state(2)  return (    <section>      <Stepper bind:value={guests} />      <p>Table for {guests.value}</p>    </section>  )}

    Only the call site decides whether a write leaves a component, so reading a parent tells you every way its state can change.

    Read Writing to a parent with bind: to learn how bind: works, how it's typed, and when to pass a box instead.

    Forms and inputs

    Every input follows the same pattern: value shows the box, and a handler writes it. warp's controls take the same props, and some, like Select and NumberField, take the box itself:

    One of each

    Someone wants 1 small oat milk coffee.

    import { state } from '@weft-js/core'import { Card, Input, NumberField, RadioGroup, Select, Switch, Textarea } from '@weft-js/warp'type Size = 'small' | 'large'export default function Order() {  const name = state('')  const note = state('')  const cups = state<number | null>(1)  const size = state<Size>('small')  const milk = state<string | null>('oat')  const iced = state(false)  return (    <Card title="One of each">      <Input label="Name" value={name.value} onInput={e => (name.value = e.currentTarget.value)} />      <Textarea label="Note" value={note.value} onInput={e => (note.value = e.currentTarget.value)} />      {/* NumberField and Select take the box itself and write it. */}      <NumberField label="Cups" min={1} max={9} value={cups} />      <Select        label="Milk"        value={milk}        options={[{ value: 'oat', label: 'Oat' }, { value: 'whole', label: 'Whole' }, { value: 'none', label: 'None' }]}      />      <RadioGroup        label="Size"        value={size.value}        onChange={v => (size.value = v)}        options={[{ value: 'small', label: 'Small' }, { value: 'large', label: 'Large' }]}      />      <Switch checked={iced.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (iced.value = e.currentTarget.checked)}>        Iced      </Switch>      <p>        {name.value || 'Someone'} wants {cups.value ?? 0} {size.value} {iced.value ? 'iced ' : ''}        {milk.value === 'none' ? 'black' : `${milk.value ?? ''} milk`} coffee{note.value ? `: “${note.value}”` : '.'}      </p>    </Card>  )}

    Try this: Change each control and watch the sentence. Every control writes a box, and the sentence reads them all.

    Read Forms and inputs to learn each kind of input, when to let the browser keep a value, and how to submit a form.

    What's next

    Head to Responding to events to start reading this chapter page by page.

    Or, if you're comfortable with these topics already, read about Managing state: how to structure it, derive values from it, and share it between components.