Lifecycle & Teardown
A Reactive instance has exactly two lifetimes it can have, and each one has a complete toolbox. This example runs both in one route.
Lives and dies with the component
Ticker's constructor runs where you new it. Constructed in <script setup>, that is inside the component's setup, so its plain watch() and its onMounted / onUnmounted hooks register against the mounting component. The interval it starts is a non-Vue resource the engine cannot know about, so an ordinary dispose() method releases it and the unmount hook delegates there. Nothing calls $stopEffects(), because the component owns every effect.
Outlives the component
Sensor manages its own watcher: start() registers a $watch in the instance's lazily created effect scope, stop() disposes just that watcher, suspend() calls $stopEffects({ reset: false }) so the watchers stop but every cached cell keeps its value, and dispose() calls $stopEffects(), where the scope stops and every cached cell is dropped, so state re-materializes fresh on the next access.
Its constructor carries the bridge from the guide, getCurrentScope() && onScopeDispose(() => this.dispose()): constructed inside a component, disposal rides that component's unmount; constructed anywhere else, the line is a no-op and the owner disposes by hand. The docs demo below writes no unmount hook because of it.
What to notice
- The scope is lazy. A Sensor that never calls
$watchallocates no effect scope at all; Ticker never allocates one, its effects are the component's. - Suspend keeps the state. After
suspend(),firedandlast changehold their values while the slider no longer triggers the watcher, andstart()resumes in a fresh scope where the counter left off. - Dispose is total, and terminal for existing bindings. After
$stopEffects()the old cells are gone; the next access materializes fresh ones. Consumers that destructured the old cells are detached by design. Stop-and-resume is whatsuspend()is for. - Cleanup is a method, never a hook. Both classes release resources through
dispose(). ivue auto-calls nothing, so there is no reserved name to remember and the same method serves a component's unmount hook, theonScopeDisposebridge, and an explicit owner alike. - Watch callbacks delegate to methods (
onTick,onTempChanged), the thin-closure rule that keeps logic named on the prototype and directly testable.
Related guide pages
- Lifecycle & Teardown — the two lifetimes,
$watch,$stopEffectsand itsreset: falseform, richer cleanup as an ordinary method, theonScopeDisposebridge. - Composables & Stores — who owns a composable's effects, and why a store never registers lifecycle hooks.
- Computed & Watch — plain
watchversus$watch, and the thin-closure rule.
The source
// LifecycleExample.ts — the route's ONE model. It hosts both lifetimes
// behind `$`-getters and forwards the refs its template reads, the same
// refine-don't-forward surface a class puts over a composable.
import type { Ref } from 'vue';
import { Reactive } from '../../ivue';
import { Sensor } from './Sensor';
import { Ticker } from './Ticker';
class $LifecycleExample {
constructor() {
// First touch INSIDE setup on purpose: Ticker's constructor registers
// plain watch() and lifecycle hooks, and those must land in the
// component's scope. Sensor bridges to the same scope on its own.
void this.$ticker;
void this.$sensor;
}
// COMPONENT-LIFETIME model — the composable-style `$`-getter: created
// once, on first touch, cached for the life of this instance
protected get $ticker() {
return new Ticker.Class();
}
// OUTLIVING model — same seam
protected get $sensor() {
return new Sensor.Class();
}
// The refs the template binds — FORWARDED, so the SFC destructures ONE
// instance. A getter returning another instance's Ref is that same cell
// (identity intact); the `Ref<…>` annotation is how the class says so.
get ticks(): Ref<number> {
return this.$ticker.ticks;
}
get crossings(): Ref<number> {
return this.$ticker.crossings;
}
get temp(): Ref<number> {
return this.$sensor.temp;
}
get fired(): Ref<number> {
return this.$sensor.fired;
}
// DERIVED — the template's labels and classes, named
get runningLabel() {
return this.$ticker.runningLabel;
}
get runningClass() {
return this.$ticker.running.value ? 'grad' : '';
}
get toggleLabel() {
return this.$ticker.running.value ? 'pause' : 'resume';
}
get watchingLabel() {
return this.$sensor.watchingLabel;
}
get watchingClass() {
return this.$sensor.watching.value ? 'grad' : '';
}
get lastChangeLabel() {
return this.$sensor.lastChangeLabel;
}
// ACTIONS — delegated to the model that owns them
toggleTicker() {
this.$ticker.toggle();
}
startWatch() {
this.$sensor.start();
}
stopWatch() {
this.$sensor.stop();
}
suspendSensor() {
this.$sensor.suspend();
}
disposeSensor() {
this.$sensor.dispose();
}
}
export namespace LifecycleExample {
export const $Class = $LifecycleExample; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}// Ticker.ts — the COMPONENT-LIFETIME shape: the constructor runs inside
// setup, so plain watch() and the lifecycle hooks register against the
// mounting component and unmount reaps them. Nothing here needs
// $stopEffects — Vue owns the lifetime. Non-Vue resources (the interval)
// are released by an ordinary method the unmount hook delegates to.
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { Reactive } from '../../ivue';
class $Ticker {
constructor() {
// plain watch — lands in the COMPONENT's scope, reaped on unmount
watch(
() => this.ticks.value,
(ticks) => this.onTick(ticks)
);
// lifecycle hooks — register against the mounting component
onMounted(() => this.startTicking());
onUnmounted(() => this.dispose());
}
// MUTABLE STATE
get ticks() {
return ref(0);
}
get running() {
return ref(false);
}
get crossings() {
return ref(0);
}
// A non-Vue resource's handle: the engine cannot release it, so a
// method does — no reserved names, no hooks, nothing auto-called.
protected get interval() {
return shallowRef<ReturnType<typeof setInterval> | null>(null);
}
// DERIVED — plain getters
get runningLabel() {
return this.running.value ? 'ON' : 'off';
}
get isAtThreshold() {
return this.ticks.value % 5 === 0 && this.ticks.value > 0;
}
startTicking() {
if (this.interval.value) return;
this.running.value = true;
this.interval.value = setInterval(() => this.tick(), 700);
}
stopTicking() {
if (this.interval.value) clearInterval(this.interval.value);
this.interval.value = null;
this.running.value = false;
}
tick() {
this.ticks.value++;
}
toggle() {
if (this.running.value) this.stopTicking();
else this.startTicking();
}
/** Every fifth tick is a "crossing" — the plain watch above delivers it. */
onTick(ticks: number) {
if (ticks % 5 === 0) this.crossings.value++;
}
/** Richer cleanup: release the timer FIRST, while state is still alive.
* The component's unmount hook delegates here; there is no engine
* scope to stop because every effect belongs to the component. */
dispose() {
this.stopTicking();
}
}
export namespace Ticker {
export const $Class = $Ticker; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}// Sensor.ts — the OUTLIVING shape: $watch registers in the instance's own
// lazily created effect scope, and the instance is started, suspended,
// resumed and disposed by hand. The onScopeDispose bridge in the
// constructor makes a component-owned Sensor ride unmount anyway.
import { getCurrentScope, onScopeDispose, ref, shallowRef } from 'vue';
import { Reactive, type ReactiveHelpers } from '../../ivue';
class $Sensor {
constructor() {
// The bridge: constructed inside a component, disposal rides its
// unmount; constructed anywhere else, this line is a no-op and the
// explicit owner calls dispose().
getCurrentScope() && onScopeDispose(() => this.dispose());
}
// MUTABLE STATE
get temp() {
return ref(20);
}
get watching() {
return ref(false);
}
get fired() {
return ref(0);
}
get lastChange() {
return ref('');
}
/** The handle of the one live watcher; null when none is registered. A
* ref like every other cell, so dispose()'s reset clears it too. */
protected get stopWatcher() {
return shallowRef<(() => void) | null>(null);
}
// DERIVED — plain getters
get watchingLabel() {
return this.watching.value ? 'ON' : 'off';
}
get lastChangeLabel() {
return this.lastChange.value || '—';
}
get watchingClass() {
return this.watching.value ? 'grad' : '';
}
get toggleLabel() {
return this.watching.value ? 'Stop watch' : 'Start $watch';
}
start() {
if (this.watching.value) return;
this.watching.value = true;
// $watch registers in the instance's lazy effect scope — allocated
// now, on the first call, never before
this.stopWatcher.value = this.$watch(
() => this.temp.value,
(newTemp: number, oldTemp: number) => this.onTempChanged(newTemp, oldTemp)
);
}
stop() {
this.stopWatcher.value?.();
this.stopWatcher.value = null;
this.watching.value = false;
}
/** Stop the watchers ONLY — `{ reset: false }` keeps every cached cell
* and its current value; start() resumes in a fresh scope. */
/** One button for both directions — stop when watching, start otherwise. */
toggleWatch() {
if (this.watching.value) this.stop();
else this.start();
}
suspend() {
this.$stopEffects({ reset: false });
this.stopWatcher.value = null;
this.watching.value = false;
}
dispose() {
// Write the initial values FIRST: a template that destructured these
// refs keeps holding the pre-dispose cells, so this is what its
// display shows after the reset. The engine reset then drops the
// cells — the next access (a remount, a new consumer) materializes
// fresh ones with these same initial values, so both worlds agree.
this.watching.value = false;
this.fired.value = 0;
this.lastChange.value = '';
this.temp.value = 20;
this.stopWatcher.value = null;
this.$stopEffects(); // stops the scope, clears every cached cell
}
onTempChanged(newTemp: number, oldTemp: number) {
this.fired.value++;
this.lastChange.value = `${oldTemp} → ${newTemp}`;
}
}
// The engine installs $watch / $stopEffects at Reactive(); merging its
// helpers gives the class body their types — one line, zero runtime.
interface $Sensor extends ReactiveHelpers {}
export namespace Sensor {
export const $Class = $Sensor; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}<script setup lang="ts">
import DemoBox from './DemoBox.vue';
import { Sensor } from '@examples/lifecycle/Sensor';
// The Sensor's constructor bridges to this component's scope
// (getCurrentScope() && onScopeDispose(() => this.dispose())), so
// unmount disposes it — no hook to write here.
const sensor = new Sensor.Class();
// the state destructure
const { temp, fired, lastChange } = sensor;
</script>
<template>
<DemoBox
title="$watch and $stopEffects, live"
note="Start registers a watcher in the instance's lazily created effect scope. Dispose calls $stopEffects: the scope stops and every cached cell is dropped, so state re-materializes fresh."
>
<div class="d-vals">
<div>
<div class="d-k">temp</div>
<div class="d-n">{{ temp }}°</div>
</div>
<div>
<div class="d-k">watcher</div>
<div class="d-n" :class="sensor.watchingClass">
{{ sensor.watchingLabel }}
</div>
</div>
<div>
<div class="d-k">fired</div>
<div class="d-n">{{ fired }}×</div>
</div>
</div>
<div class="d-row">
<input
class="d-slider"
type="range"
min="0"
max="40"
v-model.number="temp"
aria-label="temperature"
/>
</div>
<div class="d-row">
<button class="d-btn primary" type="button" @click="sensor.toggleWatch()">
{{ sensor.toggleLabel }}
</button>
<button class="d-btn" type="button" @click="sensor.suspend">Suspend (reset: false)</button>
<button class="d-btn" type="button" @click="sensor.dispose">Dispose ($stopEffects)</button>
<span v-if="lastChange" class="d-mono"><code>$watch</code> {{ lastChange }}</span>
</div>
</DemoBox>
</template>Open in StackBlitz ⚡ — the playground boots with this example's route and file active.