Skip to content

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

NotionWhat it isEffect
disconnectedThe element left the document.The registry calls $unmount(). The instance stays on its element, and a re-inserted element mounts it again.
unmountThe 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()

js
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():

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

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

ts
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 inScopeReleased by
a mounted() return valuethe mount cyclethe next $unmount()
a pending $inject() requestthe mount cyclethe next $unmount()
on<X><Event> handlers, service mixinsthe mount cyclethe next $unmount()
$read() / $write() tasksthe mount cyclethe next $unmount() cancels them
$provide() in a field initializerthe instancenothing — it dies with the element
$watchChildren() in a field initializerthe instancenothing — 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:

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

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

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

js
instance.$unmount(); // reversible, the instance stays on its element
instance.$mount(); // a new cycle, same identity

MIT Licensed