Skip to content
weftGitHub

LearnAdding interactivity

How an update reaches the DOM

In React, a state change asks React to render your component again: it calls your function, compares the new JSX with the old, and commits the difference to the DOM. Most of React's chapter on interactivity is about the consequences: render and commit, state as a snapshot, queueing updates. weft takes a different path, and this page follows it from one write to the DOM. It's short, and once you've seen it most of weft's behaviour stops being surprising.

Three steps from a write to the page

When a handler writes count.value = 1:

  1. The box changes, now. It stores the new value at once. Then it marks everything that read it: derived values that used it are marked stale, and the bits of DOM that showed it are put in a queue.
  2. The queue runs once, soon. On the next microtask, after your handler and anything else running with it has finished, weft runs the queued bindings in the order they were created. Each one reads its expression again and writes its own DOM node: one text, one attribute, one class.
  3. Derived values recompute when read. A stale derived doesn't run on its own. It recomputes the next time something reads it, which is usually one of those bindings.

Nothing in that list calls your component. It ran once, when it was created, to build its DOM and set up the bindings. After that, the bindings are what update.

You can watch this happen. Each line below is a derived that counts how many times it ran:

Who runs on a write

0 apples, computed 1 times

0 pears, computed 1 times

import { derived, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Runs() {  const apples = state(0)  const pears = state(0)  // Each derived counts its own runs, so you can see which work a write caused.  let appleRuns = 0  let pearRuns = 0  const appleLine = derived(() => ({ text: `${apples.value} apples`, runs: ++appleRuns }))  const pearLine = derived(() => ({ text: `${pears.value} pears`, runs: ++pearRuns }))  return (    <Card title="Who runs on a write">      <p>{appleLine.value.text}, computed {appleLine.value.runs} times</p>      <p>{pearLine.value.text}, computed {pearLine.value.runs} times</p>      <Button size="sm" onClick={() => apples.value++}>Add an apple</Button>      <Button size="sm" emphasis="outline" onClick={() => pears.value++}>Add a pear</Button>      <Button size="sm" emphasis="ghost" onClick={() => (apples.value = 5)}>Set apples to 5</Button>    </Card>  )}

Try this: Add an apple, then a pear. Only the line you changed counts a run. Then press Set apples to 5 twice: the second press writes the same value, so nothing runs.

Adding an apple runs the apple line and nothing else. The pear line didn't read apples, so the write never reached it. And setting apples to 5 a second time runs nothing at all: the write compared 5 with 5 using Object.is, found nothing changed, and stopped there.

What the compiler makes of your JSX

The pieces that update are made by the compiler. Take this component:

tsx
import { state } from '@weft-js/core'export function Counter() {  const count = state(0)  return (    <p class={count.value > 9 ? 'big' : 'small'}>      Clicked {count.value} times      <button type="button" onClick={() => count.value++}>+1</button>    </p>  )}

weft's compiler turns it into something like this. The real output uses generated names, and its helpers are internal, but the shape is the same:

js
// The static markup, parsed once per module and cloned for each Counter.const template = tpl('<p>Clicked <!> times<button type="button">+1</button></p>')export function Counter() {  const count = state(0)  const p = template()  const marker = p.firstChild.nextSibling  const button = p.lastChild  // One binding per element's dynamic attributes: read, then write what changed.  bind(() => (count.value > 9 ? 'big' : 'small'), (value, prev) => cls(p, value, prev))  // One binding per dynamic child: this one owns the text before the marker.  child(p, () => count.value, marker)  // The handler, stored on the element: one document listener serves every click.  button.$$click = () => count.value++  return p}delegate(['click'])

Every {…} in your JSX became a small function, and each one is a reader of the boxes it touches. When count changes, the child binding reads count.value again and replaces one text node's content. The class binding reads its expression again, and writes the attribute only if the result changed. The <p>, the button and the words around the number were made once, from the template, and are never touched again.

That is why weft has no virtual DOM and no diffing step for ordinary updates. Each binding already knows which node it owns. (Lists are the one place that compares: a keyed list matches rows by key, so it can move, add and remove rows rather than rebuild them. See Rendering lists.)

Deep divePush, then pull

weft's graph is push-pull. A write pushes a mark through everything downstream: direct readers are marked dirty, and their readers are marked "check". Nothing is computed during the push. Then the flush pulls: each queued binding asks its sources whether they really changed, and a derived marked "check" recomputes only if one of its own sources did.

This matters when a derived comes out the same. Say isEven is derived(() => count.value % 2 === 0), and a label reads isEven.value. Writing count.value from 2 to 4 marks both. During the flush, isEven recomputes, gets true again, and since that's Object.is the old value, the label is never run. Work stops at the first value that didn't change.

The order is fixed too: queued work runs in creation order, so a parent's bindings run before its children's, and a binding never sees a half-updated parent.

Reads after writes see the new value

Because the box changes at once, the next line of your handler already sees the new value. There is no snapshot:

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.

tsx
score.value = score.value + 1   // 1score.value = score.value + 1   // 2score.value = score.value + 1   // 3

Each line reads what the line before it wrote, so pressing +3 adds three.

In React, the same three calls to setScore(score + 1) add one: score is that render's snapshot, fixed at the value it had when the render ran, so all three calls ask for the same number. React's answer is an updater function, setScore(s => s + 1), which is queued and applied in order. weft needs neither: there is no snapshot to work around, and no queue of updates to reason about. A box is a place, and score.value is what's in it now.

The same holds for derived. A derived value read after a write in the same handler recomputes on that read and gives you the up-to-date answer, even though the DOM hasn't been updated yet:

tsx
import { derived, state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Basket() {  const qty = state(1)  const total = derived(() => qty.value * 12)  const addTwo = () => {    qty.value += 2    console.log(total.value)   // 36: computed on this read, from the new qty  }  return <Button onClick={addTwo}>Add two ({total.value})</Button>}

The page catches up after your handler

Writes are batched. However many boxes you write in a handler, and however many times, the DOM is updated once, after the handler returns, in a single pass. That pass runs on the next microtask: before the browser paints, and before any timer.

Usually you never notice. But code that reads the DOM in the handler, right after a write, sees the page as it was:

When the page catches up

Count: 0

Press the button.

import { flush, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Flush() {  const count = state(0)  const report = state('Press the button.')  let shown: HTMLElement | undefined  const add = () => {    count.value++    const before = shown?.textContent    // The DOM update is queued until the handler ends. flush() applies it now.    flush()    const after = shown?.textContent    report.value = `count.value was ${count.value} at once. The page said ${before}, then ${after} after flush().`  }  return (    <Card title="When the page catches up">      <p>Count: <strong ref={(el: HTMLElement) => (shown = el)}>{count.value}</strong></p>      <Button size="sm" onClick={add}>Add one</Button>      <p>{report.value}</p>    </Card>  )}

Try this: Press Add one. The box has the new number at once, but the page shows it only after flush(), or after the handler ends.

count.value is the new number at once. The <strong> still shows the old one until flush(). flush() from @weft-js/core runs the queue now, so the DOM matches your boxes before the next line.

You need it when the DOM itself is what you want next: focusing or scrolling to an element that a write just showed, or measuring one that a write just changed.

tsx
import { flush, state } from '@weft-js/core'import { Button, Input } from '@weft-js/warp'export function Rename() {  const editing = state(false)  let field: HTMLInputElement | undefined  const start = () => {    editing.value = true    flush()            // the input exists now    field?.focus()  }  return (    <div>      {editing.value        ? <Input label="Name" ref={(el: HTMLInputElement) => (field = el)} />        : <Button onClick={start}>Rename</Button>}    </div>  )}

Without flush(), field would still be undefined: the input is only made when the queue runs.

batch()

batch(fn) runs fn and returns what it returned. It changes nothing: writes are always batched until the flush. It exists so code ported from libraries where batching is opt-in can say so at the call, and so a reader of your code knows the writes inside belong together:

tsx
import { batch, type State } from '@weft-js/core'export function rename(name: { first: State<string>; last: State<string> }, first: string, last: string) {  batch(() => {    name.first.value = first    name.last.value = last  })}

If you want one change to be a single step in undo history, that's what an action in 0 is for: each action call is one transaction.

Reading without subscribing: untrack()

Every .value read inside JSX, a derived or a watch source subscribes. Now and then you want the value without the subscription: "use the unit as it is now, but don't recompute when it changes". Wrap that read in untrack:

tsx
import { derived, state, untrack } from '@weft-js/core'export function makeReading() {  const celsius = state(20)  const label = state('Kitchen')  // Recomputes when the temperature changes. The label is read as it is then, without subscribing to it.  const line = derived(() => `${untrack(() => label.value)}: ${celsius.value} °C`)  return { celsius, label, line }}

Use it sparingly. Something that should update when a box changes should read it normally. untrack in a component body whose result only the JSX reads renders the value from setup time and never updates; the compiler warns about that with W018. Handlers don't need untrack: they aren't readers, so nothing they read subscribes.

Where your code runs, and how often

Putting it together, each piece of a component runs at its own time:

CodeRuns
The component bodyOnce, when the component is created
A {…} in JSX, an attributeOnce to build, then again when a box it read changes
derived(() => …)When read, if a box it read changed since its last run
watch(() => source, act)Once now, then when source changes
A handlerWhen the event happens. Never because state changed
onMount(fn)Once, after the component's DOM is in place

This table is the reason for most of weft's rules. A value computed in the body is computed once and goes stale, so it belongs in derived (W001). An early return that depends on state is checked once (also reported as W001), so the branch belongs in JSX. A let counter changes but nothing re-reads it (W041 in the catalog; this weft doesn't report it yet). Each rule is the table, applied.

Deep diveCounting runs in a test

@weft-js/core/test can count the reactive work an interaction does. touches(fn) reports the DOM mutations and the runs one interaction caused, and toTouchAtMost(n, { runs }) turns that into an assertion, so a change that makes every row of a list re-run fails a test instead of slowing the app down later:

tsx
await expect(() => user.click(screen.getByRole('button'))).toTouchAtMost(1, { runs: 2 })

See Testing.

Recap

  • A write stores the value at once, marks its readers and queues the DOM bindings that read it.
  • The queue runs once, on the next microtask, in creation order. Each binding updates the one node it owns.
  • The component body never runs again. There's no re-render and no diff, except matching rows by key in lists.
  • A write that's Object.is the old value does nothing; a derived that comes out the same stops the update there.
  • Reads after a write see the new value, derived included. There is no snapshot and no need for updater functions.
  • The DOM catches up after the handler. flush() brings it up to date now, for focus, scroll or measuring.
  • batch() only names a batch that was happening anyway. untrack() reads without subscribing.

Try it

Challenge 1

Predict the output

What does this handler log, and what does the page show after the click?

tsx
import { derived, state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Quiz() {  const n = state(0)  const double = derived(() => n.value * 2)  const go = () => {    n.value = 5    n.value = n.value + 1    console.log(n.value, double.value)  }  return <Button onClick={go}>{n.value} doubled is {double.value}</Button>}
Show the solution

It logs 6 12, and the button shows "6 doubled is 12".

The second line reads 5, the value the first line wrote, and writes 6. Reading double.value recomputes it from the new n. The page goes from "0 doubled is 0" straight to "6 doubled is 12": the 5 was never drawn, because the DOM is updated once, after the handler.

Challenge 2

Focus the new item

This list adds an item and should then focus its field, but the focus goes nowhere. Fix it.

tsx
import { state } from '@weft-js/core'import { Button, Input } from '@weft-js/warp'export function Packing() {  const items = state<{ id: number; name: string }[]>([])  let last: HTMLInputElement | undefined  const add = () => {    items.value = [...items.value, { id: items.value.length, name: '' }]    last?.focus()  }  return (    <section>      {items.value.map(item => (        <Input key={item.id} label={`Item ${item.id + 1}`} ref={(el: HTMLInputElement) => (last = el)} />      ))}      <Button onClick={add}>Add an item</Button>    </section>  )}
Show the solution

The new row is only made when the queue runs, after add returns, so last still points at the previous field (or at nothing). Call flush() before focusing:

tsx
import { flush, state } from '@weft-js/core'import { Button, Input } from '@weft-js/warp'export function Packing() {  const items = state<{ id: number; name: string }[]>([])  let last: HTMLInputElement | undefined  const add = () => {    items.value = [...items.value, { id: items.value.length, name: '' }]    flush()    last?.focus()  }  return (    <section>      {items.value.map(item => (        <Input key={item.id} label={`Item ${item.id + 1}`} ref={(el: HTMLInputElement) => (last = el)} />      ))}      <Button onClick={add}>Add an item</Button>    </section>  )}

flush() makes the new row, its ref runs and sets last, and the focus lands on the new field.

Challenge 3

A counter that adds three

In React, this button adds one per click, not three. Write the weft version that adds three, and explain why the React version doesn't.

tsx
// Reactconst [count, setCount] = useState(0)<button onClick={() => { setCount(count + 1); setCount(count + 1); setCount(count + 1) }}>+3</button>
Show the solution
tsx
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Plus() {  const count = state(0)  const addThree = () => {    count.value++    count.value++    count.value++  }  return <Button onClick={addThree}>{count.value} +3</Button>}

In React, count is that render's snapshot, so each call asks for the same count + 1. In weft each count.value++ reads the box as the line before left it. The page still updates once, after the handler, and shows the total.