LearnEscape hatches
Refs and the DOM
Most of the time you never touch a DOM node. You describe the page in TSX, and the compiler wires every {value} to the exact text node or attribute that shows it. But some things the browser only lets you do on an element: move focus to it, scroll it into view, measure it, or hand it to a library that draws into it. For those you need the element itself, and ref is how you ask for it.
import { Button, Card, Row } from '@weft-js/warp'export default function Focus() { // The compiler assigns the element to `field` when the input is made, so nothing here assigns it. // oxlint-disable-next-line no-unassigned-vars -- the compiler assigns it through ref let field!: HTMLInputElement return ( <Card title="Focus and select"> <input ref={field} aria-label="Your name" value="Ada Lovelace" /> <Row gap="tight"> <Button size="sm" onClick={() => field.focus()}>Focus</Button> <Button size="sm" emphasis="outline" onClick={() => field.select()}>Select all</Button> <Button size="sm" emphasis="ghost" onClick={() => field.blur()}>Blur</Button> </Row> </Card> )}Try this: Press Focus, then Select all, then Blur. Each button calls a method on the one input element the ref holds.
Getting an element with ref
Put ref on any element and give it a variable. When weft makes the element, it assigns it:
import { Button } from '@weft-js/warp'export function Focus() { // oxlint-disable-next-line no-unassigned-vars -- the compiler assigns it through ref let field!: HTMLInputElement return ( <form> <input ref={field} aria-label="Your name" /> <Button onClick={() => field.focus()}>Focus the field</Button> </form> )}Three things make this work:
let, notconst. The compiler turnsref={field}into an assignment,field = theInput, so the variable must be assignable. With aconstit calls the value as a function instead (next section).!after the name tells TypeScript the variable will hold an element before you use it. Without it, TypeScript saysfieldis used before it's assigned, because it can't see the compiler's assignment either.- The comment above it. Linters with the
no-unassigned-varsrule (oxlint, and ESLint 9) flag a variable that is declared and read but never written. The write is in the compiled output, so the rule can't see it. Turn it off for that one line and say why, as above.
If you'd rather not have the ! and the comment, use the function form below. Both are common in weft's own code.
The function form
ref also takes a function. weft calls it once, with the element:
import { Button } from '@weft-js/warp'export function Focus() { let field: HTMLInputElement | undefined return ( <form> <input ref={el => (field = el)} aria-label="Your name" /> <Button onClick={() => field?.focus()}>Focus the field</Button> </form> )}Here field is honestly HTMLInputElement | undefined, so you write field?.focus(). The function form is also what you use when the element goes somewhere other than one variable: into a Map, into a library call, or out to a parent. ref takes an array of functions too, ref={[measure, el => (field = el)]}, and calls each one.
When the element exists
A component's body runs once, top to bottom, and the TSX it returns is what makes the elements. So while the body is running, the element doesn't exist yet:
export function Broken() { let field!: HTMLInputElement field.focus() // TypeError: field is undefined. The <input> below hasn't been made yet. return <input ref={field} aria-label="Name" />}Use the element in code that runs later:
- Event handlers. A click can only happen once the element is on the page.
onMount(fn). It runs once the component's elements are in the document, with every ref assigned. This is the place for work the first paint needs: measuring, focusing on arrival, starting a library.watchafter its first run. Awatchruns once straight away, while the body runs, and then on each change. On that first run the ref is still empty, so guard it with?.or start thewatchinsideonMount.
import { onMount } from '@weft-js/core'export function Search() { let field: HTMLInputElement | undefined onMount(() => field?.focus()) return <input ref={el => (field = el)} type="search" aria-label="Search" />}onMount never runs on the server, because there is no document there. Neither does ref: the server only writes HTML, and the browser makes the elements that ref points at. Code that needs an element is browser code by nature, so these are the right places for it.
An element a write just showed
Writes in weft are batched: a handler can change five boxes, and the page updates once, on the next microtask. So right after open.value = true, the element that open shows isn't in the page yet. If you need it now, call flush() first. It applies every pending update straight away:
import { flush, state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Rename() { const editing = state(false) let field: HTMLInputElement | undefined const start = () => { editing.value = true flush() // the input is in the page now, and `field` holds it field?.select() } return ( <div> {editing.value ? <input ref={el => (field = el)} value="Untitled" aria-label="Name" /> : <Button onClick={start}>Rename</Button>} </div> )}React has flushSync for the same job. You need it rarely: most DOM work belongs in onMount or a handler that runs after the update anyway.
Focusing, scrolling and measuring
These are the three jobs refs do most. Each one is a call on the element, from a handler or onMount.
Focus is el.focus(), el.select() and el.blur(), as in the demo at the top.
Scrolling is el.scrollIntoView(). When there are many elements, collect them with the function form. A ref function in a list runs once per row, when that row's element is made:
- Tom
- Maru
- Jellylorum
- Mog
- Grumpy
- Orlando
- Bustopher
- Skimbleshanks
import { Button, Card, Row } from '@weft-js/warp'const CATS = ['Tom', 'Maru', 'Jellylorum', 'Mog', 'Grumpy', 'Orlando', 'Bustopher', 'Skimbleshanks']export default function Cats() { // One element per name. The ref function runs once per row, when the row's element is made. const rows = new Map<string, HTMLElement>() const show = (name: string) => rows.get(name)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) return ( <Card title="Scroll to a cat"> <Row gap="tight" wrap> <Button size="sm" onClick={() => show('Tom')}>Tom</Button> <Button size="sm" emphasis="soft" onClick={() => show('Orlando')}>Orlando</Button> <Button size="sm" emphasis="soft" onClick={() => show('Skimbleshanks')}>Skimbleshanks</Button> </Row> <ol style="max-block-size:7rem;overflow:auto"> {CATS.map(name => ( <li key={name} ref={(el: HTMLElement) => rows.set(name, el)}>{name}</li> ))} </ol> </Card> )}Try this: Press Orlando, then Tom. The list scrolls to the element each name was given by its ref function.
import { Button } from '@weft-js/warp'export function Cats(props: { names: string[] }) { const rows = new Map<string, HTMLElement>() const show = (name: string) => rows.get(name)?.scrollIntoView({ block: 'nearest' }) return ( <div> <Button emphasis="soft" onClick={() => show('Orlando')}>Find Orlando</Button> <ol style="max-block-size:7rem;overflow:auto"> {props.names.map(name => ( <li key={name} ref={(el: HTMLElement) => rows.set(name, el)}>{name}</li> ))} </ol> </div> )}A row that is removed leaves its element in the map. That's harmless for a fixed list. For a long-lived list that changes, delete the entry where you remove the item, or look the element up when you need it (list.querySelector(...)) instead of keeping a map.
Measuring reads sizes and positions: getBoundingClientRect(), offsetWidth, scrollHeight. The element must be in the document and laid out, so measure in onMount or later. To follow a size as it changes, start a ResizeObserver in onMount and stop it in onCleanup:
The bar is 0 px wide. Resize the window too.
import { onCleanup, onMount, state } from '@weft-js/core'import { Card, Slider } from '@weft-js/warp'export default function Ruler() { const percent = state(60) const px = state(0) let bar: HTMLElement | undefined // The element exists once the component is in the page, so measuring starts there. onMount(() => { if (!bar) return const seen = new ResizeObserver(entries => { const box = entries[0]?.contentRect if (box) px.value = Math.round(box.width) }) seen.observe(bar) onCleanup(() => seen.disconnect()) }) return ( <Card title="Measure an element"> <Slider label="Width" min={10} max={100} value={percent.value} onChange={v => (percent.value = v)} format={v => `${v}%`} /> <div ref={(el: HTMLElement) => (bar = el)} style={`inline-size:${percent.value}%;block-size:0.75rem;background:var(--warp-color-accent-solid)`}></div> <p>The bar is {px.value} px wide. Resize the window too.</p> </Card> )}Try this: Drag the slider, then resize the window. The number comes from a ResizeObserver started in onMount and stopped by onCleanup.
The number on screen is state, written by the observer's callback. That's the usual shape of DOM work in weft: the element tells you something, you put it in a box, and the page follows the box like anything else.
Deep diveWhy there's no layout effect
React has useLayoutEffect for code that must run after the DOM changes but before the browser paints, such as measuring a tooltip to place it. weft doesn't need a second kind. onMount callbacks run at the end of the same flush that put the component's nodes in the page, before the browser gets a chance to paint, and a box written there is applied in that same flush. So a measure-then-place in onMount never shows a frame in the wrong place.
Refs to elements inside other components
ref on a component isn't special: it's a prop like any other. A component that wants to give one of its elements to whoever renders it takes a ref prop, a function, and puts it on that element:
import { Button, Card } from '@weft-js/warp'// A component that hands its input to whoever renders it, through a `ref` prop that is a function.function NameField(props: { label: string; ref?: ((el: HTMLInputElement) => void) | undefined }) { return ( <label> {props.label} <input ref={props.ref} /> </label> )}export default function Form() { let input: HTMLInputElement | undefined return ( <Card title="A ref through a component"> <NameField label="Name" ref={el => (input = el)} /> <Button size="sm" onClick={() => input?.focus()}>Focus the name</Button> </Card> )}Try this: Press Focus the name. The input lives in NameField, which passed it out through its ref prop.
import { Button } from '@weft-js/warp'function NameField(props: { label: string; ref?: ((el: HTMLInputElement) => void) | undefined }) { return ( <label> {props.label} <input ref={props.ref} /> </label> )}export function Form() { let input: HTMLInputElement | undefined return ( <div> <NameField label="Name" ref={el => (input = el)} /> <Button onClick={() => input?.focus()}>Focus the name</Button> </div> )}The parent passes a function, not a variable: on a component, ref={input} would pass the variable's current value, which is undefined, because the compiler only turns ref={x} into an assignment on an element. Inside NameField, ref={props.ref} is on an element, so it calls the function with the input.
This is what React calls forwarding a ref, without forwardRef. warp's components do it where it's useful: Button and Card take a ref function and call it with their element.
Values that aren't elements
React's useRef has a second job: holding a value that survives re-renders without causing one, such as a timer id. In weft that's a plain variable in the component body. The body runs once, so the variable is made once and stays:
import { onCleanup, state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Stopwatch() { const tenths = state(0) let timer: ReturnType<typeof setInterval> | undefined // not on screen, so not state const start = () => { clearInterval(timer) timer = setInterval(() => tenths.value++, 100) } const stop = () => clearInterval(timer) onCleanup(stop) return ( <p> {(tenths.value / 10).toFixed(1)} s <Button onClick={start}>Start</Button> <Button onClick={stop}>Stop</Button> </p> )}The rule for picking between them: if the page shows it, it's state; if only your code reads it, a let is enough. A let that the TSX reads never updates the page, because nothing tells the page it changed. The catalog names that W041, though this weft doesn't report it yet: a let written in a handler and read in TSX should be state.
Recap
ref={x}on an element assigns it to aletvariable;ref={el => …}calls a function with it.let x!: Twithref={x}needs the!for TypeScript and ano-unassigned-varsdisable for linters; the function form needs neither.- The element exists once the component's TSX has made it: use it in handlers,
onMount, orwatchafter its first run, never while the body runs.flush()puts a just-written change in the page now. autofocusfocuses an element when it's created, with no ref.- A component passes one of its elements out through a
refprop that is a function. - A plain
letis weft'suseReffor values: the body runs once, so it lasts as long as the component.
Try it
Challenge 1
Play and pause a video
This player's button only changes its label. Make it play and pause the video too.
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Player() { const playing = state(false) return ( <div> <Button onClick={() => (playing.value = !playing.value)}>{playing.value ? 'Pause' : 'Play'}</Button> <video src="/flower.mp4" width="300" muted playsinline /> </div> )}Show the solution
Take the <video> with a ref, and call play() or pause() in the handler, where the element exists. play() returns a promise that rejects if the browser blocks playback, so the label follows the real state through the element's own play and pause events.
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Player() { const playing = state(false) let video: HTMLVideoElement | undefined const toggle = () => { if (!video) return if (video.paused) video.play().catch(() => (playing.value = false)) else video.pause() } return ( <div> <Button onClick={toggle}>{playing.value ? 'Pause' : 'Play'}</Button> <video ref={el => (video = el)} src="/flower.mp4" width="300" muted playsinline onPlay={() => (playing.value = true)} onPause={() => (playing.value = false)} /> </div> )}Challenge 2
Focus the search field from a shortcut
Press / anywhere on the page to focus the search field, the way many sites do. Don't use addEventListener.
Hint: on(window, 'keydown', fn) from @weft-js/core/browser listens until the component goes away.
Show the solution
Listen on the window with on, and focus the element the ref holds. Skip the key when the user is already typing in a field, so / can still be typed there.
import { on } from '@weft-js/core/browser'export function SiteSearch() { let field: HTMLInputElement | undefined if (typeof window !== 'undefined') on(window, 'keydown', e => { const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement if (e.key !== '/' || typing) return e.preventDefault() field?.focus() }) return <input ref={el => (field = el)} type="search" aria-label="Search the site" placeholder="Press / to search" />}on is called while the body runs, so the component owns the listener and removes it when it goes. The handler runs later, on a key press, by which time the input exists.
Challenge 3
Scroll the newest message into view
A chat shows messages in a scrolling list. When a message is added, the list should scroll so the newest one is visible. Write Chat(props: { messages: { id: number; text: string }[] }).
Show the solution
Keep the list element with a ref, and scroll it whenever the number of messages changes. Start the watch inside onMount, for two reasons. Its first run then happens when the list exists. And effects run in the order they were created: made in onMount, the watch is newer than the list's own bindings, so on each change the new row is already in the list when it scrolls.
import { onMount, watch } from '@weft-js/core'export function Chat(props: { messages: { id: number; text: string }[] }) { let list: HTMLElement | undefined onMount(() => { watch(() => props.messages.length, () => list?.lastElementChild?.scrollIntoView({ block: 'end' })) }) return ( <ol ref={el => (list = el)} style="max-block-size:12rem;overflow:auto"> {props.messages.map(m => <li key={m.id}>{m.text}</li>)} </ol> )}Made in the body instead, the watch would be older than the list's bindings and would run first, scrolling to the row before the new one.