Skip to content

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 $watch allocates no effect scope at all; Ticker never allocates one, its effects are the component's.
  • Suspend keeps the state. After suspend(), fired and last change hold their values while the slider no longer triggers the watcher, and start() 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 what suspend() 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, the onScopeDispose bridge, 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.
  • Lifecycle & Teardown — the two lifetimes, $watch, $stopEffects and its reset: false form, richer cleanup as an ordinary method, the onScopeDispose bridge.
  • Composables & Stores — who owns a composable's effects, and why a store never registers lifecycle hooks.
  • Computed & Watch — plain watch versus $watch, and the thin-closure rule.

The source ​

ts
// 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
}
ts
// 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
}
ts
// 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
}
vue
<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 }}&deg;</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 }}&times;</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.

Released under the MIT License.