LearnEscape hatches
Reusing logic
Two components often need the same bit of behaviour: follow the pointer, debounce what was typed, know whether the network is up, count something with a reset. In React you'd write a custom hook, useSomething(), and follow the rules of hooks to call it. In weft you write a function. It calls state, derived, watch or on like a component body does, returns what the caller needs, and that's all.
Typed: “”
Searching for: “”
import { state, untrack, watch, type Derived } from '@weft-js/core'import { Card, Input } from '@weft-js/warp'/** `source`'s value once it has stopped changing for `ms`. Call it while a component is being set up. */function debounced<T>(source: () => T, ms: number): Derived<T> { const settled = state(untrack(source)) watch(source, value => { const timer = setTimeout(() => (settled.value = value), ms) return () => clearTimeout(timer) }) return settled}export default function Search() { const typed = state('') const query = debounced(() => typed.value.trim(), 400) return ( <Card title="A debounced value"> <Input label="Search" value={typed.value} onInput={e => (typed.value = e.currentTarget.value)} /> <p>Typed: “{typed.value}”</p> <p>Searching for: “{query.value}”</p> </Card> )}Try this: Type a word quickly. "Searching for" changes only once you stop for 400 ms.
A function that makes state
Here's the debounced value from the demo, on its own:
// src/lib/debounced.tsimport { state, untrack, watch, type Derived } from '@weft-js/core'/** `source`'s value once it has stopped changing for `ms`. Call it while a component is being set up. */export function debounced<T>(source: () => T, ms: number): Derived<T> { const settled = state(untrack(source)) watch(source, value => { const timer = setTimeout(() => (settled.value = value), ms) return () => clearTimeout(timer) }) return settled}And a component using it:
import { state } from '@weft-js/core'import { Input } from '@weft-js/warp'import { debounced } from '../lib/debounced'export function Search() { const typed = state('') const query = debounced(() => typed.value.trim(), 400) return ( <div> <Input label="Search" value={typed.value} onInput={e => (typed.value = e.currentTarget.value)} /> <p>Searching for “{query.value}”</p> </div> )}Nothing about debounced is special. It runs inside Search's body, so the box it makes and the watch it starts belong to Search, exactly as if you had written them there. When Search goes, the watch stops and its last timer is cleared. Each call makes its own box, so two search fields with two debounced values don't share anything.
Why it takes a function
debounced takes () => typed.value, not typed.value. That's the most important habit for functions like this.
A component body runs once, and so does a function called from it. If you passed typed.value, the function would get the string as it was at that moment, '', and never hear about a change. A getter is something the function can call again, from a watch source or a derived, and weft follows whatever the getter reads. So the input stays live.
The same goes for props. Pass () => props.delay, not props.delay. Reading a prop in the body to hand it over is also W001, because the body runs once.
What to return
Return what the caller needs, in the shape that reads best at the call:
- One value: return the box or
deriveditself, asdebounceddoes. Type itDerived<T>when callers should only read it; aState<T>fits that type, so you can return the box you write internally. - Several things: return an object,
{ count, increment, reset }. Callers destructure it or keep the object. - Actions only: return functions. They close over the boxes, so they always see the current values.
import { derived, state } from '@weft-js/core'import { Button } from '@weft-js/warp'function counter(start = 0, step = 1) { const count = state(start) const even = derived(() => count.value % 2 === 0) return { count, even, increment: () => (count.value += step), reset: () => (count.value = start), }}export function Tally() { const cups = counter() const pages = counter(10, 5) return ( <div> <Button onClick={cups.increment}>Cups: {cups.count.value}</Button> <Button onClick={pages.increment}>Pages: {pages.count.value} {pages.even.value ? '(even)' : ''}</Button> <Button emphasis="ghost" onClick={() => { cups.reset(); pages.reset() }}>Reset both</Button> </div> )}Don't destructure a box's .value out of what you return: const { count: { value } } = counter() copies the number once. Keep the box and read .value where you use it, as with props.
The one rule: call it during setup
React's rules of hooks exist because React finds each hook's state by the order of calls in each render. weft doesn't re-run components, so there's no order to keep. You can call these functions in an if, in a loop, or in another function:
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'function counter(start = 0) { const count = state(start) return { count, increment: () => count.value++ }}const NAMES = ['Tea', 'Coffee', 'Water']export function Drinks() { // A counter per name, made in a loop. React would forbid this; here it's an ordinary array. const counts = NAMES.map(name => ({ name, ...counter() })) return <p>{counts.map(c => <Button key={c.name} onClick={c.increment}>{c.name}: {c.count.value}</Button>)}</p>}What weft does care about is when you call them. watch, on, interval and the browser values attach to the owner being set up, so the function must run while one is: in a component body, in onMount, in a defineState setup, or inside createRoot. From a click handler, a timer, a promise callback or after an await, there's no owner, and what they start would never stop. weft reports the browser values there as W051, and they throw in development. Cleanup and ownership has the details, and createRoot for when you really must start something from a handler.
A function that only makes state and derived values can be called anywhere, but the same habit serves you well.
Naming
Name these functions after what they give you, in the style of weft's own: online(), visible(), debounced(), counter(), pointer(). There's no use prefix to mark them, because nothing needs to find them: no linter has to check their call order. A custom useX() hook written from React habit is one of the React-isms in weft's diagnostics catalog (W020), so a use name also reads as a mistake to anyone, or any agent, who knows weft.
Listening to the browser
A function can listen to the browser the same way a component does, with on from @weft-js/core/browser. The listener belongs to whoever called the function:
Move the pointer in here
import { state } from '@weft-js/core'import { on } from '@weft-js/core/browser'import { Card } from '@weft-js/warp'/** Where the pointer is over `el()`, as a box. The listener goes when the caller's owner does. */function pointer(el: () => HTMLElement | undefined) { const at = state({ x: 0, y: 0, inside: false }) // 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, 'pointermove', e => { const box = el()?.getBoundingClientRect() if (!box) return const x = Math.round(e.clientX - box.left), y = Math.round(e.clientY - box.top) at.value = { x, y, inside: x >= 0 && y >= 0 && x <= box.width && y <= box.height } }) return at}export default function Tracker() { let pad: HTMLElement | undefined const where = pointer(() => pad) return ( <Card title="One function, any component"> <div ref={(el: HTMLElement) => (pad = el)} style="block-size:6rem;border:1px dashed var(--warp-color-border);border-radius:var(--warp-radius-control)"> <p>{where.value.inside ? `${where.value.x}, ${where.value.y}` : 'Move the pointer in here'}</p> </div> </Card> )}Try this: Move the pointer over the dashed box. pointer() is an ordinary function that any component can call.
// src/lib/pointer.tsimport { state } from '@weft-js/core'import { on } from '@weft-js/core/browser'/** Where the pointer is over `el()`. The listener goes when the caller's owner does. */export function pointer(el: () => HTMLElement | undefined) { const at = state({ x: 0, y: 0, inside: false }) if (typeof window !== 'undefined') on(window, 'pointermove', e => { const box = el()?.getBoundingClientRect() if (!box) return const x = Math.round(e.clientX - box.left), y = Math.round(e.clientY - box.top) at.value = { x, y, inside: x >= 0 && y >= 0 && x <= box.width && y <= box.height } }) return at}It takes the element as a getter too, because the element doesn't exist yet when the function is called: the ref is assigned later, when the TSX is made. By the time a pointermove arrives, it's there.
Before writing one of these, check @weft-js/core/browser. React's documentation builds a useOnlineStatus hook as its example; in weft that's online(), already written, sharing one listener across every component that reads it. The same module has visible(), lifecycle(), media(query), on, interval and a typed channel between tabs: Reference: browser.
Sharing logic, or sharing state?
A function like counter() shares logic: every call makes its own boxes. Two components that call it count separately. When two components should see the same value, you don't want a function, you want one box that both read:
- For state the whole app shares, a cart, the signed-in user, settings, use
defineState. Its setup runs once, and every component that imports it reads the same boxes. See App-wide state with defineState. - For state one part of the page shares, pass the box down, or provide it with
contextso the subtree can read it without props. See Passing data deeply. - For two siblings, lift the box into their parent. See Sharing state between components.
You can combine them: a defineState setup can call your functions, since it runs with an owner like a component body.
Behaviours from warp-core
warp, weft's UI kit, keeps most of its behaviour in @weft-js/warp-core, a package that never imports a framework. Focus handling, tooltips, sliders, listboxes and the rest are actions: a function that takes an element and options and returns { update, destroy }. That shape is the same one Svelte calls an action, and it's how warp-core stays usable from any framework.
weft attaches an action with use from @weft-js/warp-core/weft:
import { state } from '@weft-js/core'import type { Action } from '@weft-js/warp-core'import { use } from '@weft-js/warp-core/weft'/** Grows a textarea with its text, up to `max` lines. Plain DOM: no framework in here. */function autosize(el: HTMLTextAreaElement, opts: { max: number }): Action<{ max: number }> { let max = opts.max const fit = () => { const line = parseFloat(getComputedStyle(el).lineHeight) || 20 el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, line * max)}px` } el.addEventListener('input', fit) fit() return { update(next) { max = next.max; fit() }, destroy() { el.removeEventListener('input', fit) }, }}export function Note() { const lines = state(5) let box: HTMLTextAreaElement | undefined use(autosize, () => box, () => ({ max: lines.value })) return <textarea ref={el => (box = el)} aria-label="Note" rows={1} />}use(make, el, opts) waits until the component is mounted, makes the action with the element and the current options, calls update whenever what opts reads changes, and calls destroy when the component goes. It's a reusable function like the others on this page, built from onMount, watch and onCleanup. warp's own components use it for nearly everything they do to an element.
Write an action rather than a weft function when the behaviour is pure DOM and you'd like it to work outside weft too, or when you're adding to warp-core itself. For app code, a plain weft function is usually simpler.
Recap
- React's custom hooks are plain functions in weft. They call
state,derived,watchandon, and return what the caller needs. - What they make belongs to the component that called them, and is disposed with it.
- Pass getters (
() => box.value,() => props.x) so inputs stay live; pass a box only to give write access. - There are no rules about call order: call them in loops and conditions if you like. Call them during setup, where there's an owner, not from handlers or after
await. - Name them after what they return. No
useprefix. - Each call makes its own state. To share one value, use
defineState,contextor a box in a common parent. - warp-core's actions are framework-free behaviours;
use(make, el, opts)attaches one to an element.
Try it
Challenge 1
Extract a toggle
Several components have a box that flips between true and false, with a function to flip it. Write toggle(initial) that returns { on, flip, set }, and use it in a component with two independent toggles.
Show the solution
The box and the actions live in the function; each call makes a new box.
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'function toggle(initial = false) { const on = state(initial) return { on, flip: () => (on.value = !on.value), set: (value: boolean) => (on.value = value), }}export function Settings() { const sound = toggle(true) const motion = toggle() return ( <div> <Button onClick={sound.flip}>Sound {sound.on.value ? 'on' : 'off'}</Button> <Button onClick={motion.flip}>Motion {motion.on.value ? 'on' : 'off'}</Button> <Button emphasis="ghost" onClick={() => { sound.set(false); motion.set(false) }}>All off</Button> </div> )}Challenge 2
The window's width
Write windowWidth(), which returns the window's inner width as a value that follows resizes, and works when the component is rendered on the server (where there's no window). Show it in a component.
Show the solution
Start from innerWidth in the browser and a fixed guess on the server, and listen to resize with on, which does nothing on the server and removes itself with the caller.
import { state, type Derived } from '@weft-js/core'import { on } from '@weft-js/core/browser'function windowWidth(ssr = 1024): Derived<number> { const width = state(typeof window === 'undefined' ? ssr : window.innerWidth) if (typeof window !== 'undefined') on(window, 'resize', () => (width.value = window.innerWidth)) return width}export function Layout() { const width = windowWidth() return <p>{width.value < 600 ? 'Phone layout' : 'Wide layout'} ({width.value} px)</p>}For layout, a CSS media query or media('(max-width: 599px)', { ssr: false }) from @weft-js/core/browser is often the better tool: it changes only when the answer does, not on every pixel.
Challenge 3
A countdown with a live start
Write countdown(from) that counts down once a second to zero and exposes { left, done }. from should be live: when it changes, the countdown starts over from the new number. Use it with a prop.
Show the solution
Take from as a getter, so the function can follow it. A watch on it starts a timer per run, and its cleanup clears the old one when from changes or the component goes.
import { derived, state, watch } from '@weft-js/core'function countdown(from: () => number) { const left = state(0) watch(from, start => { left.value = start const id = setInterval(() => { if (left.value > 0) left.value-- }, 1000) return () => clearInterval(id) }) return { left, done: derived(() => left.value === 0) }}export function Timer(props: { seconds: number }) { const timer = countdown(() => props.seconds) return <p>{timer.done.value ? 'Time is up' : `${timer.left.value} s left`}</p>}Passing props.seconds instead of the getter would start once from the first value, and the body read would be W001.