Lifecycle
A module and its providers have a lifecycle: they initialize, mount, unmount, and destroy. Construction and teardown are symmetric and LIFO - whatever came up last goes down first, RAII applied to React scopes.
That is what lets a service own a socket, a timer, or a subscription without a useEffect in sight: the module opens it at the right moment and is guaranteed to close it - in the right order, exactly once.
This page covers all stages of the module lifecycle: the four hooks, who participates, and the order everything runs in.
Lifecycle Phases
A module moves through four phases. Init and mount build, unmount and destroy tear down - and each phase has its own rules for order and for errors.
Init
Runs once, when the module is constructed.
- The module's own hooks join as the first participant.
- Every eager singleton is constructed, in registration order.
lazy: trueproviders are skipped. - Every constructed instance that carries a hook is adopted as a participant.
onModuleInitfires for every participant, in registration order.
A throw fails the module. Init stops at the throwing participant, the error propagates to the render that constructed the module, and the module is marked failed. Every later read through it throws: Cannot resolve X from a module whose status is "failed" - a module answers reads only once init() has armed it, and never after it failed or was destroyed. Reads anywhere below a failed module refuse too: Cannot resolve X from an unhealthy module tree - failed branch: ....
Mount
Runs when the boundary mounts - and again on every remount.
- The module attaches to its parent: this is the moment it joins
children. - If the parent is already mounted - or there is no parent - the mount cascades top-down: the module's participants get
onModuleMountin order, then each child's. - If the parent is not mounted yet, the module just attaches and waits. React runs effects bottom-up, so the topmost new module mounts last - and its cascade sweeps everything attached below it.
A throw rolls the mount back. Everything that already mounted gets its onModuleUnmount, the module detaches from its parent and fails. The rollback is best-effort: its own errors are collected and thrown together with the original mount error.
Mounting under a dying parent throws: Cannot mount a module onto a failed parent - that branch is spent, so the child could never go live under it. Mount it under a live parent, or rebuild the branch first.
Unmount
Runs when the boundary unmounts. Best-effort by design:
- Children unmount first, in reverse attach order; then the module's own participants, in reverse.
- A throwing hook does not stop the walk - every participant gets its
onModuleUnmountregardless. - The collected errors are thrown together as one
AggregateErrorafter the walk finishes.
Destroy
Deferred and asynchronous. Destroy is scheduled one macrotask after unmount - a remount within the same tick cancels it and revives the module: mount hooks run again, on the same instances. A retired module is not gone until this actually runs.
When it runs:
- The subtree is claimed synchronously: every module in it is marked and detached, children first. Destroying something already claimed is a no-op - a double destroy cannot happen.
- Each module drains its participants in reverse order.
onModuleDestroymay return a Promise - it is awaited before the drain moves on. - A throwing hook is logged to
console.errorand the drain continues - destruction always runs to the end.
A participant that appears during the drain - a lazy provider resolved by another destroy hook - is picked up and drained in the next pass.
The Four Hooks
| Hook | Fires | Times |
|---|---|---|
onModuleInit | during module init, in registration order | once |
onModuleMount | when the boundary mounts | as many as React says |
onModuleUnmount | when the boundary unmounts, in reverse order | as many as React says |
onModuleDestroy | when the module dies, children before parents | once |
Only onModuleDestroy may be async. A returned Promise is awaited before the drain moves on. The other three are synchronous - a Promise returned from them is ignored, not awaited.
Modules
A module declares the hooks in its config. Each one is called with the module's resolver:
const OrdersModule = createModuleComponent({
providers: [OrdersStore],
onModuleInit: (resolver): void => {},
onModuleMount: (resolver): void => {
resolver.resolve(OrdersStore).refresh()
},
onModuleUnmount: (resolver): void => {
resolver.resolve(Analytics).track("orders-closed")
},
onModuleDestroy: (resolver): void | Promise<void> => {}
})Module hooks are participants like any other, with one fixed seat: first in, last out. onModuleInit and onModuleMount run before any provider's; onModuleUnmount and onModuleDestroy run after every provider's.
Providers
A provider declares the hooks as methods:
class OrdersSocket {
private socket: WebSocket | null = null
onModuleInit(): void {}
onModuleMount(): void {
this.socket = openSocket()
}
onModuleUnmount(): void {
this.socket?.close()
this.socket = null
}
onModuleDestroy(): void | Promise<void> {}
}The constructor reads, mount acquires
The constructor is for reading dependencies and allocating fields. Resources - sockets, timers, subscriptions - open in onModuleMount and close in onModuleUnmount. React builds and discards render attempts routinely; an abandoned attempt then leaves nothing behind.
Lifecycle Participants
Having a hook is the declaration. A class with at least one of the four methods is a participant - no base class, no interface, no registration flag. Remove the methods and it stops participating:
// A participant
class OrdersSocket {
onModuleMount(): void {}
}
// Not a participant - nothing to call, nothing to track
class OrdersMapper {
map(order: Order): OrderRow {
return toRow(order)
}
}Only singletons participate. One instance, one death point. A transient or request instance never sees the hooks - if it holds a resource, releasing it is on whoever created it.
Lazy singletons catch up. A lazy: true provider constructed after module init gets onModuleInit immediately and onModuleMount if the module is already mounted - see Lazy. A lazy provider that was never resolved is never constructed - no instance exists, and no hook fires, onModuleDestroy included.
LIFO
The way in is the way out, reversed. That is the whole ordering model, and it holds at both levels.
Static Modules
Within one module - providers go up in registration order, down in reverse. The module's own hooks take the fixed seat: first in, last out:
const MyModule = createModuleComponent({
providers: [A, B, C],
onModuleInit: () => {},
onModuleMount: () => {},
onModuleUnmount: () => {},
onModuleDestroy: () => {},
})
// init: MyModule → A → B → C
// mount: MyModule → A → B → C
// unmount: C → B → A → MyModule
// destroy: C → B → A → MyModuleC was constructed after B, so C may depend on B - and C is torn down while B is still alive.
Across the tree - parents go up before children, children come down before parents:
const PageModule = createModuleComponent({ providers: [A] })
const OrdersModule = createModuleComponent({ providers: [B] })
const DetailsModule = createModuleComponent({ providers: [C] })
function Page() {
return (
<PageModule>
<OrdersModule>
<DetailsModule />
</OrdersModule>
</PageModule>
)
}
// init: PageModule → A → OrdersModule → B → DetailsModule → C
// mount: PageModule → A → OrdersModule → B → DetailsModule → C
// unmount: C → DetailsModule → B → OrdersModule → A → PageModule
// destroy: C → DetailsModule → B → OrdersModule → A → PageModuleDynamic Modules
Dynamic mounts join the order at their own time. A module that appears later slots in at the moment it mounts - and teardown reverses the order things actually came up, not the order they were written:
const OrdersModule = createModuleComponent({ providers: [A] })
const DetailsModule = createModuleComponent({ providers: [B] })
function OrdersPage() {
const [open, setOpen] = useState(false)
return (
<OrdersModule>
<button onClick={() => setOpen(true)}>Show details</button>
{open && <DetailsModule />}
</OrdersModule>
)
}
// 1. The OrdersPage renders: init OrdersModule → A, mount OrdersModule → A
// 2. The button opens Details: init DetailsModule → B, mount DetailsModule → B
// 3. The page leaves: unmount B → DetailsModule → A → OrdersModule
// destroy B → DetailsModule → A → OrdersModule