Lifecycle and Cleanup

Own timers, listeners, and subscriptions through scoped mount and unmount hooks.

This page describes Regor lifecycle and teardown behavior from the actual runtime flow.

MountUnmountCleanup

Start resources when their owner mounts. Release them when that owner leaves.

Try it live

Watch a component come and goLive Regor
Child is unmounted
0Total ticks
0Mounts
0Cleanups

Ready. Mount the ticker to start its interval.

Unmount the child: the tick count stops. Remount it: a fresh interval starts. The parent keeps the totals.

import { defineComponent, html, onMounted, onUnmounted, ref } from 'regor'

export function createLifecycle(interactive = false) {
  const active = ref(false)
  const toggle = () => active(!active())
  const ticks = ref(0)
  const starts = ref(0)
  const cleanups = ref(0)
  const lastEvent = ref('Ready. Mount the ticker to start its interval.')
  const SessionTicker = defineComponent(
    html`<p class="guide-ticker">
      Interval running <span aria-hidden="true">●</span>
    </p>`,
    {
      context: () => {
        let timer: ReturnType<typeof setInterval> | undefined
        onMounted(() => {
          starts(starts() + 1)
          lastEvent('onMounted → start interval')
          timer = setInterval(() => ticks(ticks() + 1), 500)
        })
        onUnmounted(() => {
          clearInterval(timer)
          cleanups(cleanups() + 1)
          lastEvent('onUnmounted → clear interval')
        })
        return {}
      },
    },
  )
  // No child is mounted during static rendering, so the build creates no timer.
  return {
    interactive,
    active,
    toggle,
    ticks,
    starts,
    cleanups,
    lastEvent,
    components: { SessionTicker },
  }
}

export const lifecycleTemplate = html` <div class="guide-demo">
  <div class="guide-session-bar">
    <button type="button" @click="toggle" :disabled="!interactive">
      {{ active ? 'Unmount ticker' : 'Mount ticker' }}</button
    ><span>{{ active ? 'Child is mounted' : 'Child is unmounted' }}</span>
  </div>
  <SessionTicker r-if="active" />
  <div class="guide-metrics">
    <div><output>{{ ticks }}</output><span>Total ticks</span></div>
    <div><output>{{ starts }}</output><span>Mounts</span></div>
    <div><output>{{ cleanups }}</output><span>Cleanups</span></div>
  </div>
  <p class="guide-event" role="status">{{ lastEvent }}</p>
  <p class="guide-hint">
    Unmount the child: the tick count stops. Remount it: a fresh interval
    starts. The parent keeps the totals.
  </p>
</div>`
import { createApp, useScope } from 'regor'
import { createQuote, quoteTemplate } from './quote'
import { createServices, servicesTemplate } from './services'
import { createProfile, profileTemplate } from './profile'
import { createLifecycle, lifecycleTemplate } from './lifecycle'

// Each guide mounts only its own island. Everything outside the root stays static.
function mount<T extends object>(
  id: string,
  create: (interactive: boolean) => T,
  template: string,
) {
  const element = document.getElementById(id)
  if (element)
    createApp(
      useScope(() => create(true)),
      { element, template },
    )
}

mount('guide-quote', createQuote, quoteTemplate)
mount('guide-services', createServices, servicesTemplate)
mount('guide-profile', createProfile, profileTemplate)
mount('guide-lifecycle', createLifecycle, lifecycleTemplate)

Mount and unmount a child ticker. Its hooks own the interval; the parent retains the tick and cleanup counts.

Runtime lifecycle order

createApp(...) runs in this order:

  1. Resolve root (element, selector, or default #app for string template shortcut).
  2. If template/json is provided, replace root content first.
  3. Run interpolation (when enabled in config).
  4. Bind directives/components.
  5. Register root cleanup callback.
  6. Call mounted lifecycle (onMounted(...) callbacks and optional context.mounted() method).

Hook registration with useScope

onMounted / onUnmounted are scope-based APIs. In practice, register them while creating context inside useScope(...):

import { createApp, html, onMounted, onUnmounted, ref, useScope } from 'regor'

class AppCtx {
  count = ref(0)
  logs: string[] = []

  constructor() {
    onMounted(() => this.logs.push('mounted'))
    onUnmounted(() => this.logs.push('unmounted'))
  }
}

const app = createApp(
  useScope(() => new AppCtx()),
  {
    element: document.querySelector('#app')!,
    template: html`<p r-text="count"></p>`,
  },
)

Calling these hooks outside an active scope throws (ComposablesRequireScope).

unmount() vs unbind()

Returned app exposes both:

  1. app.unmount()
    • Removes root node from DOM (removeNode(root)).
    • Unbind runs through deferred queue.
  2. app.unbind()
    • Unbinds root subtree immediately.
    • Keeps DOM in place.

Use unmount() when app is gone. Use unbind() when markup stays but Regor behavior must stop.

Deferred cleanup queue and drainUnbind()

removeNode(...) queues unbind work and flushes it with a short timer. This keeps removal fast, but cleanup side effects are not always immediate in the same tick.

drainUnbind() forces pending queue flush now.

Typical test teardown:

import { createApp, drainUnbind, html, ref } from 'regor'

const root = document.createElement('div')
const app = createApp(
  { n: ref(1) },
  { element: root, template: html`<p r-text="n"></p>` },
)

app.unmount()
await drainUnbind()

Component lifecycle notes

Component teardown is registered through unbinders on component markers/host nodes. When component subtree is unbound:

  1. onUnmounted(...) callbacks run for component context.
  2. context.unmounted?.() runs if defined.
  3. Directive observers/listeners are detached.
  4. Slot-switch helper contexts are released.

ComponentHead.unmount()

Inside component context, head.unmount() is a force-remove tool:

  1. Removes nodes between component start/end markers.
  2. Calls unmounted lifecycle for captured component contexts.

Use only when component wants to self-remove its rendered block.

Match every resource with its cleanup

Pair intervals with clearInterval, listeners with removeEventListener, and subscriptions with their stop function.

Practical cleanup patterns

  1. Page-level app: Prefer app.unmount() on route/page disposal.
  2. Keep DOM, disable behavior: Use app.unbind().
  3. Test suites: app.unmount() + await drainUnbind() in finally.
  4. Manual reactive resources: If you create observers/effects outside scoped/component lifecycle, stop them explicitly.

See Also

  1. API: createApp
  2. API: useScope
  3. API: onMounted
  4. API: onUnmounted
  5. API: removeNode
  6. API: drainUnbind
  7. API: unbind