Refs
Two forms, one rule each: callback refs are the canonical low-level form,
object refs are the convenient one, and .current is nulled the moment
the DOM it points to dies.
html`<canvas ref="${(el) => draw(el)}"></canvas>`; // callback: receives the element
import { ref } from 'lemonadejs';
const box = ref(); // object ref (useRef-style)
html`<div ref="${box}"></div>`; // box.current = the element
On components, the same prop carries the imperative api instead:
box.current becomes whatever the component passed to props.ref?.(api).
Background
Two failure modes apply to DOM refs in reactive systems:
- Dangling references. After a node is removed, a live reference to it can pin the detached subtree in the heap, especially in long-lived closures such as debounced handlers, intervals, or module-level caches. Nulling the reference after removal is otherwise the author’s responsibility.
- Phantom subscriptions. In fine-grained reactive engines, a ref callback runs while a template is materializing. If the callback reads a reactive value in a tracked context, the enclosing binding silently subscribes to it, and the branch rebuilds on unrelated state changes. An invisible dependency created by a side-effect callback cannot be seen by reading the code.
How LemonadeJS solves it
Refs run untracked, an engine guarantee, not a convention. The
runtime wraps every callback ref in untracked(): reads inside the ref
never collect dependencies, so the phantom-subscription class does not
exist. The same discipline covers component setup bodies and
subscribe() callbacks: imperative code is imperative everywhere.
Object refs are nulled on disposal, any disposal. The cleanup is registered with the build that created the element, not just the component. That means a branch swap nulls the ref too:
const box = ref();
html`<div>${() => (on.value
? html`<span ref="${box}">a</span>`
: html`<em>off</em>`)}</div>`;
on.value = false; // the span is disposed → box.current === null
on.value = true; // fresh branch → box.current is the NEW element
.current can never point at a removed node (a surviving ref cannot pin a
dead subtree; the destroy suites prove it with heap
snapshots), and after a rebuild it points at the current element, not a
stale one.
Component refs carry the api, with the same hygiene. A component
exposes its imperative surface by calling props.ref?.(api). Pass an
object ref and the runtime normalizes it into a setter before setup, so the
component always sees a callable props.ref, and nulls .current on
unmount, guarded by identity so a ref that was re-pointed elsewhere is
left alone.
Refs fire after the nodes attach, an ordering guarantee. The
callback runs in the same synchronous update, but only once the element
is connected to the document. focus(), getBoundingClientRect() and
anything else that needs a live, laid-out element work directly inside
the ref: no queueMicrotask, no isConnected check, no deferral
idiom is ever needed:
html`<input ref="${(el: HTMLInputElement) => el.focus()}" />`
That line is also the typing convention: annotate the ref parameter
with the type you know: you wrote the tag, so you know it is an
HTMLInputElement. No as casts; the engine stays flexible on its
side (SVG and exotic elements accept any signature).
Firing semantics worth knowing: a ref fires when its element is built. Detaching and reattaching a cached branch (hiding and showing the same content) does not re-fire it; only a rebuild does. Components that need per-show setup re-arm it from state instead; the modal documents this exact pattern in its source.
In practice
Callback refs as plumbing. The dropdown block wires its root and its virtualization scroller with plain callbacks, no intermediate state:
html`<div class="lm-dropdown" ref="${(el) => (root = el)}" ...>
<div class="lm-dropdown-lazy" ref="${(el) => (scroller = el)}"
onscroll="${onScroll}">...</div>
</div>`
These run once per build, untracked: reading props or states inside
them could never subscribe the dropdown’s branches by accident.
A component api through an object ref, from the engine’s own suite:
const Counter = component('counter', {
api: { add: Function, total: Function },
}, (props, { state }) => {
const n = state(0);
props.ref?.({ add: () => n.value++, total: () => n.value });
return html`<b>${n}</b>`;
});
const counter = ref();
html`<${Counter} ref="${counter}" />`;
counter.current.add(); // imperative surface, typed by the contract
// after unmount: counter.current === null. The api (and its closure
// over the component's states) is released
The branch-rebuild re-pointing test. The engine’s refs suite pins the
exact lifecycle shown above: flip a branch away and box.current is
null; flip it back and box.current is the fresh element, provably not
the old one (box.current !== firstEl). A ref in v6 is a live pointer,
never a souvenir.
Reference
// Callback form: the canonical low-level ref
html`<p ref="${(el) => { ... }}"></p>` // runs once per build, UNTRACKED
html`<input ref="${(el: HTMLInputElement) => el.focus()}" />`
// annotate the type you KNOW, no casts;
// fires AFTER attach: focus()/rects work
// Object form: useRef-style
import { ref, type Ref } from 'lemonadejs';
const r = ref<HTMLElement>(); // { current: null }
const n = ref(5); // optional initial value
html`<p ref="${r}"></p>` // r.current = the element
// On components
props.ref?.(api) // component side: publish the api
html`<${C} ref="${r}" />` // r.current = the api
html`<${C} ref="${(api) => ...}" />` // callback form works identically
interface Ref<T> { current: T | null }
Rules worth memorizing: refs run untracked, so never use one to build a
reactive dependency (that is computed()‘s job); refs
fire on build, not on reattach of a cached branch; .current is nulled on
any disposal, so check it before use in long-lived callbacks. null
means the DOM is gone, and that is the correct answer.