Lifecycles
What You're Used To
React has useEffect with cleanup returns. Vue has onMounted/onUnmounted. Svelte has onDestroy. Every framework needs a way to clean up when a component leaves the DOM — clear intervals, close WebSockets, unsubscribe from stores.
In React, this looks like:
jsxuseEffect(() => {
const interval = setInterval(tick, 1000);
return () => clearInterval(interval); // cleanup
}, []);The cleanup function runs when the component unmounts. Miss it, and you leak memory.
CellUI's Model
CellUI components execute once. There's no mount/unmount cycle, no re-render, no lifecycle hooks in the traditional sense. Instead, CellUI provides one primitive:
CellUI.onDispose(node, callback)
Register a cleanup callback on a DOM node. When that node is removed from the document, the callback fires automatically:
typescriptimport { CellUI, view } from '@cmj/cellui';
function Clock() {
const time = signal(new Date().toLocaleTimeString());
const interval = setInterval(() => {
time.value = new Date().toLocaleTimeString();
}, 1000);
const el = document.createElement('div');
CellUI.onDispose(el, () => {
clearInterval(interval);
console.log('Clock cleaned up');
});
el.appendChild(view`<span>${time}</span>`);
return el;
}When the <div> is removed from the DOM (by when() toggling, by each() removing an item, or by manual removal), the interval is cleared. No return function, no cleanup array, no dependency tracking.
How it works under the hood
CellUI uses a global MutationObserver on document.body that watches for removed nodes. When a node is removed:
- CellUI checks a
WeakMap<Node, Array<() => void>>for registered callbacks - If found, all callbacks fire
- If the removed node has children with registered callbacks, those fire too (deep cleanup)
- The
WeakMapentry is deleted
The WeakMap ensures that if a node is garbage collected before removal, the registry doesn't prevent collection.
Multiple callbacks on the same node
typescriptCellUI.onDispose(el, () => clearInterval(timer1));
CellUI.onDispose(el, () => clearInterval(timer2));
CellUI.onDispose(el, () => ws.close());
// All three fire when el is removedError isolation
If one cleanup callback throws, the remaining callbacks still fire:
typescriptCellUI.onDispose(el, () => { throw new Error('cleanup failed'); });
CellUI.onDispose(el, () => clearInterval(timer));
// timer IS cleared, error is logged to console with code [CellUI:003]Common Patterns
Timer cleanup
typescriptfunction Timer() {
const seconds = signal(0);
const container = document.createElement('div');
const id = setInterval(() => seconds.value++, 1000);
CellUI.onDispose(container, () => clearInterval(id));
container.appendChild(view`<p>Elapsed: ${seconds}s</p>`);
return container;
}Event listener cleanup
typescriptfunction ScrollTracker() {
const scrollY = signal(0);
const container = document.createElement('div');
const handler = () => { scrollY.value = window.scrollY; };
window.addEventListener('scroll', handler);
CellUI.onDispose(container, () => window.removeEventListener('scroll', handler));
container.appendChild(view`<p>Scroll: ${scrollY}px</p>`);
return container;
}Third-party library cleanup
typescriptfunction Chart(data: Signal<number[]>) {
const container = document.createElement('canvas');
// Initialize chart library after DOM insertion
setTimeout(() => {
const chart = new ChartJS(container, { data: data.value });
// Update chart when data changes
data.subscribe(newData => chart.update(newData));
// Destroy chart when canvas is removed
CellUI.onDispose(container, () => chart.destroy());
}, 0);
return container;
}When NOT to use onDispose
Signal→DOM bindings don't need cleanup. CellUI uses
WeakReffor DOM bindings. When a node is garbage collected, the signal automatically drops its subscription. You never need to manually unsubscribe signals from DOM nodes.Effect subscriptions are permanent. If you create an
effect(), it runs for the lifetime of the page. There's noeffect.dispose(). If you need conditional effects, gate them withuntracked()or restructure your component.Simple components don't need onDispose. If your component is just signals + view template with no timers, listeners, or third-party libraries, there's nothing to clean up. CellUI's WeakRef system handles it.
CellUI.dispose(container)
Manually trigger cleanup for a container and all its children:
typescriptconst killSwitch = await mount('#app', () => App());
// Later: tear down the entire app
killSwitch();
// Calls CellUI.dispose(container), which:
// 1. Fires onDispose callbacks for the container
// 2. Walks all child elements, fires their callbacks too
// 3. Dispatches 'cellui:unmount' event
// 4. Clears container.innerHTMLThis is the "kill switch" pattern — useful for micro-frontends or widget embedding where you need to fully remove CellUI from a section of the page.