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:
- The box changes, now. It stores the new value at once. Then it marks everything that read it:
derivedvalues that used it are marked stale, and the bits of DOM that showed it are put in a queue. - 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.
- Derived values recompute when read. A stale
deriveddoesn'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:
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:
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:
// 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:
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.
score.value = score.value + 1 // 1score.value = score.value + 1 // 2score.value = score.value + 1 // 3Each 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:
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:
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.
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:
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