Lifecycle
Most components need no lifecycle at all. mx has no onMount/onDestroy, no componentDidMount - state flows through $(), the reconciler patches the DOM, and that's the whole story for the vast majority of components.
A few components own something with a start and a stop tied to being on the page: a timer, a subscription, a ResizeObserver, a global listener. The instinct is to ask "which lifecycle hook?" - but the better question is who owns the resource, and how long does it live? Get that right and the lifecycle problem usually disappears.
Up front, because it surprises people
You almost never need to reach for Web Components / connectedCallback to clean up after a component. There is no component that strictly requires it - everything below has a registration-free answer. disconnectedCallback is the cleanest tool for one specific case, not a requirement. mx is fully compatible with custom elements if you want them (that's its own page), but cleanup is not the reason to adopt them.
First choice: own less
The cleanest teardown is the one you never write. If a resource can live on a long-lived owner - the app shell, a parent that outlives its children - put it there, and let the leaf components be stateless render-from-props functions. A live clock is the textbook case:
// Leaf: pure render-from-props. No timer, no listener, no lifecycle.
define('a-clock', { $({ time }) { return mx.time(time); } });
// One long-lived owner (the app shell) holds the timer AND the single
// visibility listener. It lives as long as the page, so it never needs
// teardown - and clocks can be added/removed freely with zero leak risk.
let tick = () => document.querySelectorAll('a-clock')
.forEach(c => c.$({ time: new Date().toLocaleTimeString() }));
let timer;
let sync = () => {
clearInterval(timer);
if (!document.hidden) { tick(); timer = setInterval(tick, 1000); } // pause/resync on tab switch
};
document.addEventListener('visibilitychange', sync);
sync();Now the leaf <a-clock> owns nothing: no timer, no listener, no lifecycle. The reconciler can create and drop clocks however it likes - there is nothing to leak. The single setInterval and the single visibilitychange listener live on the app, which lives for the whole page, so they never need to be torn down. Lifting the resource up didn't just centralize it - it dissolved the cleanup problem entirely.
This is the Update-All pattern
Pushing derived state from one owner into many stateless components is exactly the updateAll() pattern. For anything "live" - dashboards, tickers, clocks, feeds - reach for this first. A leaf that reacts to a shared source almost always wants to be stateless, with the source owned above it.
When the resource must live on the element
Sometimes ownership genuinely belongs to the component - a self-contained widget you drop in anywhere, with no owner to hand the resource to. Then you do need to release it when the element leaves. What you reach for depends on the resource.
A self-polling timer → check isConnected
A setInterval/setTimeout/rAF already runs on its own schedule, so it can check whether it's still wanted and clear itself. No registration, and it's robust across every removal path - render() replacing it, an ancestor being removed, anything - because isConnected reflects document-attachment regardless of how the element was detached:
define('a-clock', {
$() {
// The interval checks isConnected each tick and clears ITSELF once
// the element leaves the document - by ANY removal path. No registration.
this._t ||= setInterval(() => {
if (!this.isConnected) return clearInterval(this._t);
this.render(mx.time(new Date().toLocaleTimeString()));
}, 1000);
return mx.time('...');
}
});Trade-offs, stated honestly: it cleans up lazily - one extra tick after removal - and it only works for resources that poll. A passive subscription has no tick on which to check isConnected, so this technique does nothing for it.
A passive subscription → a removal hook
A bus.on(...), an addEventListener on document/window, a ResizeObserver - these wait for an external event and never poll. After the element is removed they keep firing against dead DOM and hold the element alive. They need an actual signal that the element left. Two ways, neither requiring Web Components:
Option A - a self-disconnecting MutationObserver
Watch a stable ancestor; when you notice you've been detached, clean up and disconnect the observer so it doesn't leak either:
define('price-ticker', {
$() {
if (!this._init) {
this._init = true;
this._off = bus.on('price', p => this.render(mx.span('$' + p))); // passive subscription
// A removal hook with no custom element: watch a stable ancestor,
// clean up the moment we're detached, then disconnect ourselves.
this._mo = new MutationObserver(() => {
if (!this.isConnected) { this._off(); this._mo.disconnect(); }
});
this._mo.observe(document.body, { childList: true, subtree: true });
}
return mx.span('...');
}
});This works in any browser and needs no registration. The costs: the observer fires on every DOM mutation under document.body (so it's real overhead if many components do it), and it's microtask-async rather than synchronous.
Option B - connectedCallback / disconnectedCallback
If the component is (or can be) a registered custom element, the platform hands you the exact callbacks. This is the cleanest option - synchronous, per-element, zero overhead until removal, less code - and it fires on every removal path:
class PriceTicker extends HTMLElement {
connectedCallback() { this._off = bus.on('price', p => this.render(mx.span('$' + p))); }
disconnectedCallback() { this._off(); } // fires on every removal path, synchronously
}
customElements.define('price-ticker', PriceTicker);"Cleanest" is not "required"
Option B is lighter and sharper than Option A, but it is not necessary - Option A covers the same ground without a custom element. Choose disconnectedCallback when the component is already a registered custom element (see Web Components for rendering mx inside one), not as a reason to register one. If you're writing a plain define() component and just need cleanup, the MutationObserver - or better, lifting the resource up - keeps you out of custom-element land entirely.
Two things that look like cleanup but leak
Both of these seem to give a component control over its own teardown. Neither survives the reconciler.
Overriding remove()
Since define() does Object.assign(element, definition), you can shadow Element.prototype.remove with your own:
define('bad-clock', {
remove() { Element.prototype.remove.call(this); clearInterval(this._t); },
$() { this._t ||= setInterval(() => this.render(mx.time('' + Date.now())), 20); return mx.time('...'); }
});It fires when something calls the element's own .remove() - a manual call, or the reconciler's trailing-excess cleanup. But the reconciler's dominant removal path is replaceWith (a position-matched child whose tag changed), which never touches your override - and ancestor removal detaches the whole subtree without calling any child's remove(). Measured:
remove() override - manual .remove() 3 -> 3 stopped (fired)
remove() override - reconciler replaceWith 3 -> 9 *** LEAK ***
remove() override - ancestor removed 7 -> 19 *** LEAK ***
Mistaking imperative control for a lifecycle
Exposing $start/$stop methods (the methods-mixin) gives you control over a resource - but no signal for when the element is removed. So a passive listener registered in $ has nobody to remove it:
define('bad-clock', {
$startClock() { this._t ||= setInterval(() => this.render(mx.time('...')), 1000); return this; },
$stopClock() { clearInterval(this._t); return this; },
$() {
// A passive document listener with nobody to remove it. When the element
// is reconciled away, this listener stays on document forever - and on the
// next tab switch it RESTARTS the interval on a detached element.
document.addEventListener('visibilitychange', this._onVis ||= () =>
this[document.hidden ? '$stopClock' : '$startClock']());
return mx.time('...');
}
});When the parent reconciles this away, $ never runs again, nothing calls $stopClock, and the visibilitychange listener stays on document permanently - reviving the interval on a detached element every time the tab is switched. The methods let you stop the clock; they don't tell the component it's gone.
The common thread
Both anti-patterns assume removal happens through a path you control. In a reconciler-driven UI it usually doesn't - a parent re-renders and the child vanishes via replaceWith or an ancestor swap, with no hook for you to hang teardown on. That's why the working answers are: lift the resource up (no removal to detect), poll isConnected (the element notices itself), a MutationObserver (the DOM tells you), or disconnectedCallback (the browser tells you).
First-run setup (no teardown needed)
Not everything that runs once needs cleanup. For "do this the first time, never again" work - lazy-loading a library, building a persistent child, a one-time fetch - a flag on this is enough. It survives every later re-render and needs no lifecycle:
define('code-editor', {
$({ value = '' }) {
if (!this._ready) {
this._ready = true;
// Wait until we're in the DOM before measuring layout, then init once.
let boot = () => this.isConnected ? setupEditor(this, value) : requestAnimationFrame(boot);
boot();
}
return [];
}
});The isConnected poll inside handles the one wrinkle: some libraries (editors, chart engines) must measure layout, which only works once the element is in the document. Poll a frame at a time until isConnected, then initialize. No registration, no teardown - the editor instance lives and dies with the element, and most such libraries are reclaimed with their container.
What you do not have to clean up
Cleanup discipline matters, but don't over-rotate - the browser reclaims plenty for free:
- Handlers on the element -
onclick in a render description, or this.onscroll. Removed with the element; mx also nullifies stale render-assigned handlers via its stamp system. - Child DOM - everything
render() built under the element goes away when the element does. - Closures over render-local state - collected once nothing references them.
What does not get reclaimed automatically - the actual teardown checklist:
setInterval/setTimeout - fire until cleared.- Observers - deliver until
disconnect(). - Listeners on
document/window/other nodes - outlive the element. - Event-bus / store / socket subscriptions - hold the element alive until unsubscribed.
- Third-party instances (charts, editors, maps) - leak their internals until you call their
destroy().
The decision, in order
| Situation | Reach for | Web Components? |
|---|
| Renders from props | define(), no lifecycle | No |
| A "live" leaf (clock, ticker, feed) | Lift timer/listener to a long-lived owner; leaf stays stateless | No |
| Bare self-polling timer on the element | isConnected check inside the tick | No |
| Passive subscription on the element | Self-disconnecting MutationObserver... | No |
| ...or, if already a custom element | connectedCallback/disconnectedCallback (cleanest) | Yes (optional) |
| One-time setup, no teardown | First-run guard if (!this._ready) | No |
The throughline
There is no cleanup problem in mx that forces you into Web Components. The honest order is: own less, then let the element notice its own removal (isConnected / MutationObserver), and reach for disconnectedCallback only when the component is already a registered custom element - where it is genuinely the nicest tool. mx is built to work inside custom elements when you want them; cleanup just isn't the reason to.
Related: Web Components (using mx inside registered custom elements), the event bus (what subscriptions connect to), Update-All (lifting state to one owner), and the attribute cache (why render-assigned handlers self-clean).