Skip to content

Refs and DOM Access

Use .ref() for direct DOM access.


The .ref() method takes a callback that runs when the element is mounted:

const AutofocusInput = ({ el }: Service) => () => {
return el('input').ref((node) => {
node.focus();
})();
};

Return a cleanup function from the callback:

const ResizeObserved = ({ el, signal }: Service) => () => {
const dimensions = signal({ width: 0, height: 0 });
return el('div').ref((node) => {
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
dimensions({ width, height });
});
observer.observe(node);
// Return cleanup function
return () => observer.disconnect();
})(
// ... children
);
};

The cleanup runs when the element is removed from the DOM.


If a parent needs access to a child’s DOM node, pass a callback:

const input = ({ el }: Service) => (props: { onRef?: (node: HTMLInputElement) => void }) => {
return el('input').ref((node) => {
props.onRef?.(node);
})();
};
const Form = (svc: Service) => () => {
const { el } = svc;
const Input = svc(input); // Wired component gets PascalCase
let inputNode: HTMLInputElement | null = null;
return el('form')(
Input({ onRef: (node) => { inputNode = node; } }),
el('button').props({
onclick: () => inputNode?.focus()
})('Focus Input')
);
};

Or with signals for reactive access:

const Form = (svc: Service) => () => {
const { el, signal } = svc;
const Input = svc(input);
const inputRef = signal<HTMLInputElement | null>(null);
return el('form')(
Input({ onRef: (node) => inputRef(node) }),
el('button').props({
onclick: () => inputRef()?.focus()
})('Focus Input')
);
};

Use refs to integrate non-reactive libraries:

const Chart = ({ el, effect }: Service) => (props: { data: Readable<ChartData> }) => {
return el('canvas').ref((canvas) => {
// Initialize chart library
const chart = new ChartLibrary(canvas, {
data: props.data()
});
// Update chart when data changes
const disposeEffect = effect(() => {
chart.update(props.data());
});
// Return cleanup
return () => {
disposeEffect();
chart.destroy();
};
})();
};

For collections, store refs in a map:

const ScrollableList = ({ el, signal, map }: Service) => () => {
const items = signal(['a', 'b', 'c', 'd', 'e']);
const itemRefs = new Map<string, HTMLElement>();
const scrollToItem = (id: string) => {
itemRefs.get(id)?.scrollIntoView({ behavior: 'smooth' });
};
return el('div')(
el('div')(
...['a', 'b', 'c', 'd', 'e'].map(id =>
el('button').props({ onclick: () => scrollToItem(id) })(`Go to ${id}`)
)
),
el('div').props({ style: 'height: 200px; overflow: auto' })(
map(items, (item) =>
el('div').props({ style: 'height: 100px' }).ref((node) => {
itemRefs.set(item(), node);
return () => itemRefs.delete(item());
})(item)
)
)
);
};

Refs that set up observers, listeners, or other subscriptions need cleanup to avoid memory leaks:

// ❌ WRONG - observer never disconnected
const Measured = (svc: Service) => () => {
const { el, signal } = svc;
const size = signal({ width: 0, height: 0 });
return el('div').ref((node) => {
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
size({ width, height });
});
observer.observe(node);
// Missing cleanup! Observer lives forever
})();
};
// ✅ CORRECT - return cleanup function
const Measured = (svc: Service) => () => {
const { el, signal } = svc;
const size = signal({ width: 0, height: 0 });
return el('div').ref((node) => {
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
size({ width, height });
});
observer.observe(node);
return () => observer.disconnect(); // Cleanup when unmounted
})();
};