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.
Start resources when their owner mounts. Release them when that owner leaves.
Try it live
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:
- Resolve root (
element,selector, or default#appfor string template shortcut). - If
template/jsonis provided, replace root content first. - Run interpolation (when enabled in config).
- Bind directives/components.
- Register root cleanup callback.
- Call mounted lifecycle (
onMounted(...)callbacks and optionalcontext.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:
app.unmount()- Removes root node from DOM (
removeNode(root)). - Unbind runs through deferred queue.
- Removes root node from DOM (
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:
onUnmounted(...)callbacks run for component context.context.unmounted?.()runs if defined.- Directive observers/listeners are detached.
- Slot-switch helper contexts are released.
ComponentHead.unmount()
Inside component context, head.unmount() is a force-remove tool:
- Removes nodes between component start/end markers.
- Calls unmounted lifecycle for captured component contexts.
Use only when component wants to self-remove its rendered block.
Pair intervals with clearInterval, listeners with removeEventListener, and subscriptions with their stop function.
Practical cleanup patterns
- Page-level app: Prefer
app.unmount()on route/page disposal. - Keep DOM, disable behavior: Use
app.unbind(). - Test suites:
app.unmount()+await drainUnbind()infinally. - Manual reactive resources: If you create observers/effects outside scoped/component lifecycle, stop them explicitly.