LearnEscape hatches
Escape hatches
So far, everything has stayed inside weft: state in boxes, values derived from them, TSX that follows both. That covers most of an app. Some things live outside it, though: a DOM element you need to focus, a socket to a chat server, a timer, a map widget, a request that answers later, a canvas. This chapter is about stepping outside, safely, and coming back. Most apps need these tools in a few places, and the first lesson of each page is often that you need them less than you think.
Refs and the DOM
Some things only an element can do: take focus, scroll into view, report its size. ref gives you the element, as a variable or through a function:
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.
import { Button } from '@weft-js/warp'export function Focus() { let field: HTMLInputElement | undefined return ( <div> <input ref={el => (field = el)} aria-label="Your name" /> <Button onClick={() => field?.focus()}>Focus</Button> </div> )}Because a component runs once, a plain variable holds the element for its whole life: there's no useRef and no .current.
Read Refs and the DOM to learn when the element exists, how to focus, scroll and measure, and how a component hands one of its elements to its parent.
Effects
An effect keeps something outside weft in step with state. watch(source, act) runs act now and whenever what source read changes, and a function it returns cleans up before the next run:
import { state, watch } from '@weft-js/core'import { Card, Segmented } from '@weft-js/warp'type Room = 'general' | 'travel' | 'music'export default function ChatRoom() { const room = state<Room>('general') const log = state<string[]>([]) const say = (line: string) => (log.value = [...log.value.slice(-5), line]) // Runs now, and again whenever `room` changes. The function it returns undoes this run first. watch(() => room.value, name => { say(`Connecting to #${name}…`) const done = setTimeout(() => say(`Connected to #${name}`), 500) return () => { clearTimeout(done) say(`Disconnected from #${name}`) } }) return ( <Card title="One connection at a time"> <Segmented label="Room" value={room.value} onChange={v => (room.value = v)} options={[{ value: 'general', label: 'general' }, { value: 'travel', label: 'travel' }, { value: 'music', label: 'music' }]} /> <ol>{log.value.map((line, i) => <li key={i}>{line}</li>)}</ol> </Card> )}Try this: Switch rooms a few times, quickly. Each change disconnects the old room before it connects the new one, and a quick switch never says it connected.
import { state, watch } from '@weft-js/core'export function Room() { const room = state('general') watch(() => room.value, name => { const socket = new WebSocket(`wss://chat.example/${name}`) return () => socket.close() }) return <p>#{room.value}</p>}There's no dependency array: weft follows what the source reads.
Read Effects to learn how watch, onMount and onCleanup work, when they run, and how @weft-js/core/browser turns browser events into values.
You might not need an effect
Most effects in other frameworks copy one value into another, or run code because of a click. In weft, a value computed from others is derived, and code caused by an event goes in its handler:
import { derived, state } from '@weft-js/core'export function Name() { const first = state('Ada') const last = state('Lovelace') const full = derived(() => `${first.value} ${last.value}`) // not a watch that writes a box return <p>{full.value}</p>}Read You might not need an effect to learn the common cases, how to start a component's state over, why data loading isn't an effect, and which diagnostics catch these.
Cleanup and ownership
Every watch, listener and timer belongs to an owner: the conditional branch, list row or root that made it. When the owner goes, what it owns stops:
Ticking: 0 s
import { onCleanup, onMount, state } from '@weft-js/core'import { interval } from '@weft-js/core/browser'import { Card, Switch } from '@weft-js/warp'function Ticker(props: { onLog: (line: string) => void }) { const seconds = state(0) // Owned by the branch that rendered this component: it stops when the branch goes. interval('1s', () => seconds.value++) onMount(() => props.onLog('Ticker mounted, interval started')) onCleanup(() => props.onLog(`Ticker removed after ${seconds.value} s, interval cleared`)) return <p>Ticking: {seconds.value} s</p>}export default function Owner() { const shown = state(true) const log = state<string[]>([]) const add = (line: string) => (log.value = [...log.value.slice(-3), line]) return ( <Card title="What a branch owns"> <Switch checked={shown.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (shown.value = e.currentTarget.checked)}> Show the ticker </Switch> {shown.value && <Ticker onLog={add} />} <ol>{log.value.map((line, i) => <li key={i}>{line}</li>)}</ol> </Card> )}Try this: Turn the ticker off and on. Its interval stops when the branch goes, with no cleanup code for the interval itself.
import { state } from '@weft-js/core'import { interval } from '@weft-js/core/browser'export function Ticker() { const seconds = state(0) interval('1s', () => seconds.value++) // cleared when this component's branch goes return <p>{seconds.value} s</p>}Read Cleanup and ownership to learn what is disposed when, how to clean up what you start yourself, and how createRoot makes an owner where there isn't one.
Reusing logic
React has custom hooks. weft has functions: anything that makes state, derived values or effects can move out of a component into a function, and each call gets its own:
import { derived, state } from '@weft-js/core'import { Button } from '@weft-js/warp'function counter(start = 0) { const count = state(start) return { count, double: derived(() => count.value * 2), increment: () => count.value++ }}export function Tally() { const cups = counter() return <Button onClick={cups.increment}>{cups.count.value} cups, {cups.double.value} halves</Button>}Read Reusing logic to learn why there are no rules about call order, why you pass getters, and how warp-core's behaviours attach with use.
Async and loading states
resource is derived for data that arrives later. It refetches when what it read changes and cancels the request it no longer needs. <Boundary> says what to show while the first answer loads; after that, pending tells you a newer one is on its way, so you can dim the old one instead of emptying the page:
Press Next day for a forecast.
No request cancelled yet
import { Boundary, resource, state } from '@weft-js/core'import { Button, Card, Row, Spinner, Text } from '@weft-js/warp'const WEATHER = ['Sunny, 21°', 'Rain, 14°', 'Windy, 17°']const cancelled = state(0)// Stands in for a request: answers after 700 ms, or stops when `signal` says the answer isn't wanted any more.const fetchWeather = (day: number, signal: AbortSignal) => new Promise<{ day: number; text: string }>((done, fail) => { let answered = false const t = setTimeout(() => { answered = true; done({ day, text: WEATHER[day % WEATHER.length]! }) }, 700) // The next fetch aborts this one's signal even after it answered; only a request still waiting is cancelled. signal.addEventListener('abort', () => { if (answered) return clearTimeout(t) cancelled.value++ fail(new Error('superseded')) }) })function Today(props: { day: number }) { // Reads props.day before it waits: a new day fetches again and aborts the fetch still in flight. const weather = resource(signal => fetchWeather(props.day, signal)) return ( <Row gap="tight"> {/* A refetch keeps the last forecast, dimmed, with a spinner beside it until the new one lands. */} <p aria-busy={weather.pending ? 'true' : 'false'} style={weather.pending ? 'opacity: 0.5' : ''}> Day {weather.value?.day}: {weather.value?.text} </p> {weather.pending && weather.value !== undefined && <Spinner label={`Loading day ${props.day}`} />} </Row> )}export default function Forecast() { const day = state(0) return ( <Card title="Forecast"> {day.value === 0 ? <p>Press Next day for a forecast.</p> : ( // The first load has nothing to keep, so the Boundary shows its fallback until the answer arrives. <Boundary pending={<Row gap="tight"><Spinner label="Loading the forecast" /><span>Loading…</span></Row>}> <Today day={day.value} /> </Boundary> )} <Row gap="tight"> <Button onClick={() => day.value++}>Next day</Button> <Text role="caption" subtle>{cancelled.value === 0 ? 'No request cancelled yet' : `${cancelled.value} earlier ${cancelled.value === 1 ? 'request' : 'requests'} cancelled`}</Text> </Row> </Card> )}Try this: Press Next day a few times quickly. The old forecast stays, dimmed, and the count shows each earlier request being cancelled.
import { Boundary, resource } from '@weft-js/core'import { Spinner } from '@weft-js/warp'function Report(props: { city: string }) { const report = resource(signal => fetch(`/api/weather/${props.city}`, { signal }).then(r => r.text())) return ( <p aria-busy={report.pending ? 'true' : 'false'}> {report.value} {report.pending && report.value !== undefined && <Spinner label="Updating" />} </p> )}// The resource is made inside the Boundary, so the Boundary knows to wait for it.export function Weather(props: { city: string }) { return ( <Boundary pending={<p>Loading…</p>}> <Report city={props.city} /> </Boundary> )}Read Async and loading states to learn how refetches keep the page still, how errors and retries work, and how loaded data is rendered on the server.
Text, media and shapes
Some content needs to hold its place: text that hasn't arrived, text that streams, images, skeletons, canvases. weft has a small module for each, so nothing moves as the page fills in:
import { onCleanup, state } from '@weft-js/core'import { Text } from '@weft-js/core/text'import { Button, Card } from '@weft-js/warp'const REPLY = 'Streams arrive a few characters at a time. A plain element grows and reflows with every token, and the lines above can move. With stream, the height only grows and in whole lines.\n\nA finished paragraph is never rewritten, so only the last one changes while more text arrives.'export default function Reply() { const text = state('') let timer: ReturnType<typeof setInterval> | undefined const send = () => { clearInterval(timer) text.value = '' let at = 0 // Stands in for a model's answer: three characters every 30 ms. timer = setInterval(() => { at += 3 text.value = REPLY.slice(0, at) if (at >= REPLY.length) clearInterval(timer) }, 30) } onCleanup(() => clearInterval(timer)) return ( <Card title="Streamed text"> <Button size="sm" onClick={send}>Ask</Button> <Text stream>{text.value}</Text> </Card> )}Try this: Press Ask and watch the text arrive. The box grows a whole line at a time, and the first paragraph stays still once it is done.
import { Text } from '@weft-js/core/text'export function Reply(props: { text: string }) { return <Text stream>{props.text}</Text>}Read Text, media and shapes to learn about <Text>, <Media>, <Boundary pending="shape">, <Surface> and the @weft-js/text plugin.
Recap
refreaches an element; a plainletholds it, because a component runs once.watchkeeps outside things in step with state, with no dependency array;onMountandonCleanupmark arrival and departure.- Most would-be effects are
derivedvalues or handler code. - Owners decide when work stops;
createRootmakes one by hand. - Reusable logic is a plain function called during setup.
resourceand<Boundary>load data;<Text>,<Media>, shapes and<Surface>keep the page still.
What's next
Head to Refs and the DOM to start the chapter.