Skip to content

$watch & $stopEffects

Sensor manages its own watcher: start() registers a $watch in the instance's lazily created effect scope, stop() disposes just that watcher, and dispose() calls $stopEffects() — the scope stops and every cached cell is dropped, so state re-materializes fresh on the next access.

What to notice

  • The scope is lazy. An instance that never calls $watch allocates no effect scope at all.
  • Dispose is total. After $stopEffects(), the old cells are gone; touching any ref-getter materializes a fresh cell.
  • The watch callback delegates to a method (onTempChanged) — the thin-closure rule keeps logic named on the prototype and directly testable.

The source

ts
// Sensor.ts — $watch and $stopEffects on an instance you control by hand.
import { ref } from 'vue';
import { Reactive } from '../../ivue';

class $Sensor {
  get temp() {
    return ref(20);
  }
  get watching() {
    return ref(false);
  }
  get fired() {
    return ref(0);
  }
  get lastChange() {
    return ref('');
  }

  #stopWatch?: () => void;

  start() {
    if (this.watching.value) return;
    this.watching.value = true;
    // $watch registers in the instance's lazy effect scope
    this.#stopWatch = (this as any).$watch(
      () => this.temp.value,
      (newTemp: number, oldTemp: number) => this.onTempChanged(newTemp, oldTemp),
    );
  }

  stop() {
    this.#stopWatch?.();
    this.#stopWatch = undefined;
    this.watching.value = false;
  }

  dispose() {
    (this as any).$stopEffects(); // stops the scope, clears every cached cell
    this.watching.value = false; // fresh cells materialize on next access
  }

  onTempChanged(newTemp: number, oldTemp: number) {
    this.fired.value++;
    this.lastChange.value = `${oldTemp}${newTemp}`;
  }
}

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 { onUnmounted } from 'vue';
import { Sensor } from './Sensor';

const sensor = new Sensor.Class();

// the state destructure
const {
  // state refs
  temp,
  watching,
  fired,
  lastChange,
} = sensor;

onUnmounted(() => sensor.$stopEffects());
</script>

<template>
  <div class="pane">
    <p class="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.
    </p>
    <div class="vals">
      <div>
        <div class="k">temp</div>
        <div class="n">{{ temp }}°</div>
      </div>
      <div>
        <div class="k">watcher</div>
        <div class="n" :class="watching ? 'grad' : ''">
          {{ watching ? 'ON' : 'off' }}
        </div>
      </div>
      <div>
        <div class="k">fired</div>
        <div class="n">{{ fired }}×</div>
      </div>
      <div>
        <div class="k">last change</div>
        <div class="n mono">{{ lastChange || '—' }}</div>
      </div>
    </div>
    <div class="row">
      <input
        class="slider"
        type="range"
        min="0"
        max="40"
        v-model.number="temp"
        aria-label="temperature"
      />
      <button class="btn primary" type="button" @click="sensor.start()">
        start
      </button>
      <button class="btn" type="button" @click="sensor.stop()">stop</button>
      <button class="btn" type="button" @click="sensor.dispose()">
        dispose
      </button>
    </div>
  </div>
</template>

<style scoped src="../example-pane.css"></style>

Open in StackBlitz ⚡ — the playground boots with this example's route and file active.

Released under the MIT License.