---
title: "Refs and Direct DOM Access in LemonadeJS"
description: "Refs and the real DOM: callback refs and object refs both run outside dependency tracking and are nulled on disposal, branch swaps included."
source: https://lemonadejs.com/docs/refs/
---

# Refs and the real DOM

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**.

```javascript
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)`.

A callback ref receives the element after it is attached; an object ref keeps `current` up to date.

<!--example-->

```js
import { html, ref } from 'lemonadejs';

const App = (props, { state }) => {
    const box = ref();                        // object ref: box.current is the element
    const size = state('');

    const measure = () => {
        const r = box.current.getBoundingClientRect();
        size.value = Math.round(r.width) + ' × ' + Math.round(r.height) + ' px';
    };

    return html`<div>
        <textarea ref="${box}" rows="3" style="width:70%">resize me, then measure</textarea>
        <p><button onclick="${measure}">measure box.current</button> ${size}</p>
        <input ref="${(el) => (el.value = 'callback ref received <' + el.tagName.toLowerCase() + '>')}" style="width:70%" />
    </div>`;
};
```

## 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:

```javascript
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](/docs/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:

```javascript
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:

```javascript
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:

```javascript
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

```javascript
// 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()](/docs/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.