Skip to content

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.

  1. The module's own hooks join as the first participant.
  2. Every eager singleton is constructed, in registration order. lazy: true providers are skipped.
  3. Every constructed instance that carries a hook is adopted as a participant.
  4. onModuleInit fires 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.

  1. The module attaches to its parent: this is the moment it joins children.
  2. If the parent is already mounted - or there is no parent - the mount cascades top-down: the module's participants get onModuleMount in order, then each child's.
  3. 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:

  1. Children unmount first, in reverse attach order; then the module's own participants, in reverse.
  2. A throwing hook does not stop the walk - every participant gets its onModuleUnmount regardless.
  3. The collected errors are thrown together as one AggregateError after 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:

  1. 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.
  2. Each module drains its participants in reverse order. onModuleDestroy may return a Promise - it is awaited before the drain moves on.
  3. A throwing hook is logged to console.error and 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

HookFiresTimes
onModuleInitduring module init, in registration orderonce
onModuleMountwhen the boundary mountsas many as React says
onModuleUnmountwhen the boundary unmounts, in reverse orderas many as React says
onModuleDestroywhen the module dies, children before parentsonce

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:

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

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

tsx
// 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:

tsx
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 → MyModule

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

tsx
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 → PageModule

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

tsx
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

Where to go next

Guides

Reference

MIT licensed.