LearnEscape hatches
Effects
Most of a weft app is state, derived values and TSX. A box changes, and everything that reads it follows: text, attributes, lists, other derived values. None of that needs an effect. Effects are for the rest: keeping something outside weft in step with your state. A connection to a chat server, a timer, a third-party map widget, the page's title. weft can't update those by itself, because they aren't part of what your component renders.
In this demo, choosing a room opens a connection to it. When you choose another room, the old connection closes first:
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.
watch: run code when something changes
watch takes two functions:
import { state, watch } from '@weft-js/core'export function ChatRoom() { const room = state('general') watch(() => room.value, name => { const connection = connect(name) return () => connection.close() }) return <p>Welcome to #{room.value}</p>}const connect = (name: string) => ({ close: () => console.log(`closed #${name}`) })- The source,
() => room.value, says what to follow. weft notes every box it reads. - The act,
name => { … }, is the side effect. It gets the source's value, and the previous value as a second argument (undefinedthe first time). - If the act returns a function, that is its cleanup. weft calls it before the next act, and when the component goes away.
The act runs once straight away, as the component is set up, and again each time something the source read changes. So with three room changes you get: connect to general; close general, connect to travel; close travel, connect to music; and when the component is removed, close music. There is always exactly one open connection, and never one left behind.
Following several values
The source can read as much as it likes. Return what the act needs, a tuple or an object:
import { state, watch } from '@weft-js/core'export function Connection() { const room = state('general') const server = state('eu.chat.example') watch(() => [server.value, room.value] as const, ([host, name]) => { const socket = new WebSocket(`wss://${host}/${name}`) return () => socket.close() }) return <p>#{room.value} on {server.value}</p>}The act runs when either changes. weft decides by what the source read, not by comparing what it returned: a new tuple every time is fine. The flip side is that if the source computes something from several boxes, say a.value + b.value, the act runs whenever a or b changes, even if the sum came out the same. When that matters, compute the value in a derived and read that in the source: a derived only tells its readers when its result really changes.
Deep divewatch and useEffect
React's useEffect(fn, [deps]) and weft's watch(source, act) do the same job: keep something outside in step with state, with a cleanup in between. The differences come from components running once.
- No dependency array. The source is your dependency list, and weft builds it by watching what the source reads. You can't forget a dependency, and there's no lint rule to keep the list honest. A dependency array written out of React habit is one of the React-isms in weft's diagnostics catalog (W020).
- It runs when it's created.
useEffectruns after React commits and the browser paints. Awatchruns its act synchronously during setup, then whenever its source changes, before the page updates. For work that needs the rendered elements, useonMount(below). - No double run in development. React's Strict Mode mounts each component twice to flush out missing cleanups. weft doesn't, because nothing re-runs your component body. Write the cleanup anyway: it runs every time the source changes, and when the component goes.
- No stale closures. A React effect sees the props and state of the render that made it. A weft act reads
.valuewhen it runs, so it always sees the current value.
onMount and onCleanup
Two smaller tools cover a component's arrival and departure.
onMount(fn) runs once, after the component's elements are in the document and every ref is set. That's the place for DOM work: measuring, focusing, handing an element to a library. onCleanup(fn) runs when the component goes away.
import { onCleanup, onMount } from '@weft-js/core'export function Chart() { let canvas: HTMLCanvasElement | undefined onMount(() => { const chart = drawChart(canvas) onCleanup(() => chart.destroy()) }) return <canvas ref={el => (canvas = el)} width="400" height="200" aria-label="Sales by month" />}const drawChart = (el: HTMLCanvasElement | undefined) => ({ destroy: () => el?.remove() })onCleanup inside onMount belongs to the component, like one in the body: onMount runs your function under the component's owner. If the component is removed before it ever mounted, the onMount callback is skipped.
Here's the stopwatch from earlier pages again. A watch starts and stops the timer, and a function ref gives the button its input:
0.0 s
import { state, watch } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function Stopwatch() { const running = state(false) const tenths = state(0) const picked = state(false) let lap: HTMLInputElement | undefined // Runs when `running` changes. The function it returns cleans up before the next run, and when the component goes. watch(() => running.value, on => { if (!on) return const id = setInterval(() => tenths.value++, 100) return () => clearInterval(id) }) return ( <Card title="Stopwatch"> <p><strong>{(tenths.value / 10).toFixed(1)} s</strong></p> <Button onClick={() => (running.value = !running.value)}>{running.value ? 'Stop' : 'Start'}</Button> <input ref={(el: HTMLInputElement) => (lap = el)} aria-label="Lap" readOnly value={`Lap at ${(tenths.value / 10).toFixed(1)} s`} /> <Button emphasis="ghost" tone="neutral" onClick={() => { lap?.focus(); lap?.select(); picked.value = true }}>Select the lap</Button> {picked.value && <p>Selected. Copy it with Ctrl+C or Cmd+C.</p>} </Card> )}Try this: Start it, stop it, then press Select the lap.
The watch there is the whole timer: if (!on) return means a stopped watch starts nothing, and the returned clearInterval stops the old timer whenever running flips or the stopwatch is removed.
When effects run
Writes in weft are batched. A handler can change five boxes, and nothing else runs until it returns. Then, on the next microtask, weft runs every effect and DOM update that the writes touched, once each:
- Effects run in the order they were created. A component is created before its children, so a parent's effects run before its children's.
- DOM bindings are effects too, so they're in the same queue. An effect created in the body is older than the bindings of the TSX the body returns, so it runs before them; one created in
onMountis newer, and runs after. - When the queue is empty, waiting
onMountcallbacks run. If they write state, that's applied in the same pass.
flush() runs that queue now instead of on the next microtask, which you need when a handler must see the page as a write left it (focusing an element it just showed, say).
Nothing runs on the server
With server rendering, a component's body runs on the server to make HTML. state and derived work there, so the HTML has the right content. But watch, onMount and effect don't run on the server at all: there's no document, no socket worth opening, and nothing to clean up after the page is sent. They run in the browser once it takes the page over. So an effect is also the safe place for code that touches window or document.
effect: the compiler's tool
@weft-js/core also exports effect(compute, apply). It's what the compiler emits for each live part of your TSX: compute is tracked, and apply writes the DOM with the new value and the previous one.
import { effect, state } from '@weft-js/core'export function Title() { const unread = state(3) effect(() => unread.value, n => (document.title = n ? `(${n}) Inbox` : 'Inbox')) return <p>{unread.value} unread</p>}You can call it, but watch is the one to write: it gives the act its previous value and a cleanup, and reads more plainly. effect is there for the rare binding you write by hand.
Browser events as values
A lot of effects in other frameworks exist to listen to the browser: is the network up, is the tab visible, does the user prefer dark mode, which key was pressed. @weft-js/core/browser turns those into values you read like a box:
Network online
This page is active, and your system prefers light.
Keys pressed while this page has focus: 0
import { state } from '@weft-js/core'import { lifecycle, media, on, online } from '@weft-js/core/browser'import { Badge, Card } from '@weft-js/warp'export default function Status() { // Each is a value that follows the browser. Their listeners go away with this component. const isOnline = online() const page = lifecycle() const dark = media('(prefers-color-scheme: dark)', { ssr: false }) const presses = state(0) // The server renders this too, and has no window: on() would do nothing there, but its argument has to exist. if (typeof window !== 'undefined') on(window, 'keydown', () => presses.value++) return ( <Card title="The browser, as values"> <p>Network <Badge tone={isOnline.value ? 'positive' : 'critical'}>{isOnline.value ? 'online' : 'offline'}</Badge></p> <p>This page is <strong>{page.value}</strong>, and your system prefers <strong>{dark.value ? 'dark' : 'light'}</strong>.</p> <p>Keys pressed while this page has focus: {presses.value}</p> </Card> )}Try this: Press a few keys, switch to another window and back, or turn your network off. Each line follows the browser with no listener code in the component.
import { state } from '@weft-js/core'import { interval, media, on, online, visible } from '@weft-js/core/browser'export function Toolbar() { const isOnline = online() const shown = visible() const dark = media('(prefers-color-scheme: dark)', { ssr: false }) const now = state(Date.now()) const open = state(true) if (typeof window !== 'undefined') on(window, 'keydown', e => { if (e.key === 'Escape') open.value = false }) interval('1s', () => (now.value = Date.now())) return ( <p hidden={!open.value}> {isOnline.value ? 'Online' : 'Offline'}, {dark.value ? 'dark' : 'light'}, {shown.value ? 'visible' : 'hidden'}, {new Date(now.value).toLocaleTimeString()} </p> )}online(),visible(),lifecycle()andmedia(query)are read-only values. Every component that reads one shares a single listener, and the listener goes when the last reader does.on(target, type, fn)isaddEventListenerthat removes itself when the component goes. The event is typed from the target:eabove is aKeyboardEvent.interval(every, fn)issetIntervalthat clears itself, with durations like'500ms','1s'or'5m'.
On the server, each value is its server value (online and visible are true), and on and interval do nothing. media can't know the screen, so pass what the server should render: media(query, { ssr: false }). Leaving it out in a server-rendered component is W054. The full list, with tabs and the page lifecycle, is in Reference: browser.
Raw addEventListener, setInterval, new WebSocket or new EventSource in a component with no matching cleanup is W050, with an edit to on or interval where one fits. You can still use them: remove the listener, clear the interval or close the socket in onCleanup or a watch cleanup, and the warning goes.
Two more tools
untrack(() => …)reads boxes without following them. Inside aderivedor awatchsource, it lets you read a value without re-running when it changes. In a component body it's a one-time snapshot that never updates, so it isn't for anything you render (W018).batch(fn)runsfnand returns its result. Writes are always batched until the next flush, so it does nothing extra; it exists to say so at the call, for readers coming from libraries where batching is opt-in.
Recap
- Effects keep things outside weft in step with state. What weft renders follows state by itself.
watch(source, act): the source is followed, the act isn't. The act runs now, then whenever what the source read changes. A returned function cleans up before the next run and when the component goes.- There's no dependency array: weft finds dependencies by watching what the source reads.
onMountruns once the elements are in the document;onCleanupruns when the component goes.- Effects run on the next microtask, in creation order, and never on the server.
flush()runs them now. @weft-js/core/browsergives youonline,visible,lifecycle,media,onandinterval, which clean up after themselves. Create them in setup, not in handlers.
Try it
Challenge 1
Pause the countdown when the tab is hidden
This countdown keeps ticking in a background tab. Make it pause while the page isn't visible, and carry on when it comes back.
import { state } from '@weft-js/core'import { interval } from '@weft-js/core/browser'export function Countdown() { const left = state(60) interval('1s', () => { if (left.value > 0) left.value-- }) return <p>{left.value} s left</p>}Show the solution
Follow visible() with a watch, and run a timer only while it's true. The returned cleanup clears it when the page is hidden, and when the countdown is removed.
import { state, watch } from '@weft-js/core'import { visible } from '@weft-js/core/browser'export function Countdown() { const left = state(60) const shown = visible() watch(() => shown.value, on => { if (!on) return const id = setInterval(() => { if (left.value > 0) left.value-- }, 1000) return () => clearInterval(id) }) return <p>{left.value} s left</p>}The act can't use interval here: it runs again on each change, outside setup, so nothing would own a new interval. A raw timer with a returned cleanup is exactly what watch is for.
Challenge 2
Keep the page title in step
Show the number of unread messages in the tab's title, as (3) Inbox, and put the title back to what it was when the component goes away.
Show the solution
A watch on the count writes document.title. Remember the title from before the component came, and restore it in onCleanup.
import { onCleanup, state, watch } from '@weft-js/core'import { Button } from '@weft-js/warp'export function Inbox() { const unread = state(3) const before = typeof document === 'undefined' ? '' : document.title watch(() => unread.value, n => { document.title = n ? `(${n}) Inbox` : 'Inbox' }) onCleanup(() => (document.title = before)) return <Button onClick={() => unread.value++}>New message ({unread.value})</Button>}The body runs on the server too, where there's no document, hence the typeof check. The watch and the cleanup only run in the browser. In an app with @weft-js/start, a route's meta sets the title for you: see Metadata and share images.
Challenge 3
Find the effect that shouldn't be one
This component works, but it has an effect it doesn't need. Find it and remove it.
import { state, watch } from '@weft-js/core'export function Price(props: { amount: number; quantity: number }) { const total = state(0) watch(() => props.amount * props.quantity, t => { total.value = t }) return <p>Total: {total.value.toFixed(2)}</p>}Show the solution
total is computed from props, so it's a derived, not state kept in step by a watch. The watch version holds a second copy of the truth, and it's wrong on the server: watch doesn't run there, so the HTML says 0.00.
import { derived } from '@weft-js/core'export function Price(props: { amount: number; quantity: number }) { const total = derived(() => props.amount * props.quantity) return <p>Total: {total.value.toFixed(2)}</p>}The next page, You might not need an effect, is all about this.