Lifecycle
mount and unmount are the whole lifecycle. There is no third, permanent notion: a component never declares that its work is over, and nothing marks an instance as never mountable again.
The two notions
| Notion | What it is | Effect |
|---|---|---|
| disconnected | The element left the document. | The registry calls $unmount(). The instance stays on its element, and a re-inserted element mounts it again. |
| unmount | The reversible opposite of mount. | Unbinds the cycle's listeners, runs the mounted() cleanups, cancels the scheduled tasks, calls unmounted(). |
A move gives one removal record and one addition record: the instance is unmounted and then mounted again, with the same identity, and the state of the cycle starts over. This is the behaviour of disconnectedCallback and connectedCallback for custom elements.
Unmounting a parent does not unmount its children. Each element answers for itself.
mounted() and unmounted()
import { Base } from '@studiometa/js-toolkit';
class Player extends Base {
static config = { name: 'Player' };
mounted() {
console.log('the element is in the document');
}
unmounted() {
console.log('the element left, or the declaration was withdrawn');
}
}mounted() returns its cleanup
mounted() can return a function, or an array of functions, sync or async. They run on the next $unmount():
import { Base, useScroll } from '@studiometa/js-toolkit';
class Header extends Base {
static config = { name: 'Header' };
mounted() {
// `subscribe()` returns its own unsubscribe — hand it straight back.
return useScroll().subscribe(({ directionY }) => {
this.$el.classList.toggle('is-hidden', directionY > 0);
});
}
}Several cleanups: return an array.
import { Base, useResize, useScroll } from '@studiometa/js-toolkit';
class Sticky extends Base {
static config = { name: 'Sticky' };
mounted() {
return [useScroll().subscribe(() => {}), useResize().subscribe(() => {})];
}
}An async mounted() works the same way:
import { Base, createContext, type Signal } from '@studiometa/js-toolkit';
const CountContext = createContext<Signal<number>>('count');
class TodoCount extends Base {
static config = { name: 'TodoCount' };
async mounted() {
const signal = await this.$inject(CountContext);
// Released on unmount, even if the await resolved after it.
return signal.subscribe((count) => {
this.$el.textContent = String(count);
});
}
}If an async mounted() resolves after the unmount, the cleanup runs immediately.
unmounted() stays available for the cases the returned cleanup does not fit.
What is unmount-scoped, and what is not
| Registered in | Scope | Released by |
|---|---|---|
a mounted() return value | the mount cycle | the next $unmount() |
a pending $inject() request | the mount cycle | the next $unmount() |
on<X><Event> handlers, service mixins | the mount cycle | the next $unmount() |
$read() / $write() tasks | the mount cycle | the next $unmount() cancels them |
$provide() in a field initializer | the instance | nothing — it dies with the element |
$watchChildren() in a field initializer | the instance | nothing — it dies with the element |
A component whose declaration is withdrawn therefore keeps providing context until its element goes.
"Do this once per element"
Because $unmount() leaves the instance on its element, a plain field survives every move, re-insertion and swap() that preserves the element. "Once per element" is instance state, not a lifecycle decision:
import { Base } from '@studiometa/js-toolkit';
class Reveal extends Base {
static config = { name: 'Reveal' };
hasRevealed = false;
mounted() {
if (this.hasRevealed) return;
this.$el.classList.add('is-revealed');
this.hasRevealed = true;
}
}What a field does not survive is an element that is genuinely replaced — which is exactly when the work should run again.
Withdrawing a declaration
When an element stops declaring a component — the token leaves data-component, or a responsive declaration stops matching — the registry unmounts the instance and drops it from the element, so declaring the name again builds a new one.
That is the registry rearranging its own bookkeeping. The instance only ever sees $unmount().
Announcements
Every instance dispatches a framework event on mount and on unmount, carrying itself in the payload:
import { EVENTS, type LifecycleEventDetail } from '@studiometa/js-toolkit';
document.addEventListener(EVENTS.component.mounted, (event) => {
const { instance } = (event as CustomEvent<LifecycleEventDetail>).detail;
console.log(`${instance.$id} mounted`);
});- The mount event bubbles from the element, so any ancestor can follow its descendants with no declaration.
- The unmount event dispatches from
document, because the element can already be detached. - An instance that is scheduled but not mounted announces nothing.
See $watchChildren() for the component-level API built on this.
Waiting for the DOM to settle
whenDOMSettled() is the completion boundary for morphing, fetch updates and breakpoint crossings. It drains the pending mutation records, follows the mutation chains of eager lifecycle work, and resolves after the eager mounts and the teardown:
import { whenDOMSettled } from '@studiometa/js-toolkit';
async function replace(el: Element, html: string) {
el.innerHTML = html;
await whenDOMSettled();
// Every eager component in the new markup has mounted.
}It does not wait for visibility, interaction, idle or media conditions, and it does not await the promises returned by mounted(). swap() awaits it for you.
Manual mounting
$mount() and $unmount() are public, and calling them is legitimate — but on a page the registry does it, and doing it yourself is nearly always a sign that a mount strategy is the answer instead.
instance.$unmount(); // reversible, the instance stays on its element
instance.$mount(); // a new cycle, same identity