Skip to content

Plain getter vs computed() ​

One class, one dependency (celsius), two derivations: fahrenheit is a plain getter, status is a computed(). The run counters under each value show exactly when each body executes.

What to notice ​

  • Drag the slider: celsius is a dependency of both, so both bodies run — memoization never skips a real dependency change.
  • Click re-render: the plain getter re-derives (that is its deal — zero bytes, re-run per render) while the computed body stays frozen. That skip is what its ~300 bytes per instance buy.
  • The run counters are plain fields — incrementing them inside getter bodies is side-effect-free for the reactive graph.

The source ​

ts
// Thermo.ts — plain getter vs computed(), side by side.
import { computed, ref } from 'vue';
import { Reactive } from '../../ivue';

class $Thermo {
  // Run counters live in one plain object: incrementing them inside getter
  // bodies is side-effect-free for the graph (a plain write triggers nothing).
  readonly runs = { fahrenheit: 0, status: 0 };

  get celsius() {
    return ref(21);
  }

  // Plain getter: re-derives on EVERY render — even unrelated ones.
  get fahrenheit() {
    this.runs.fahrenheit++;
    return Math.round((this.celsius.value * 9) / 5 + 32);
  }

  // computed: render-suppression — memoized; its body runs only when celsius
  // actually changed (the demo's whole point, side by side with the getter).
  get status() {
    return computed(() => this.deriveStatus());
  }

  deriveStatus() {
    this.runs.status++;
    const celsius = this.celsius.value;
    if (celsius < 10) return 'Cold';
    if (celsius < 18) return 'Cool';
    if (celsius < 26) return 'Comfortable';
    return 'Warm';
  }
}

export namespace Thermo {
  export const $Class = $Thermo; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}
ts
// DerivedExample.ts — the route's view state, in ivue: it hosts the Thermo
// instance, forwards the refs the template binds, and mirrors Thermo's
// plain-field run counters into refs the template can display (a
// plain-field write triggers nothing — that's the point the demo makes —
// so a post-flush watcher does the mirroring).
import { onMounted, ref, watch, type ComputedRef, type Ref } from 'vue';
import { Reactive } from '../../ivue';
import { Thermo } from './Thermo';

class $DerivedExample {
  constructor() {
    onMounted(() => this.startMirroring());
  }

  // HOSTED model — created on first touch, held for the life of the view
  protected get $thermo() {
    return new Thermo.Class();
  }

  /** The model, exposed for the template's dotted reads. */
  get thermo() {
    return this.$thermo;
  }

  // FORWARDED cells — the model's refs, so the SFC destructures ONE instance
  get celsius(): Ref<number> {
    return this.$thermo.celsius;
  }
  get status(): ComputedRef<string> {
    return this.$thermo.status;
  }

  // Unrelated state — changing it re-renders the demo without touching celsius.
  get ticks() {
    return ref(0);
  }
  get fahrenheitRunsShown() {
    return ref(0);
  }
  get statusRunsShown() {
    return ref(0);
  }

  // DERIVED — plain getters
  get fahrenheit() {
    return this.$thermo.fahrenheit;
  }
  get reRenderLabel() {
    return `re-render (${this.ticks.value})`;
  }

  startMirroring() {
    this.syncRunCounters();
    watch([() => this.celsius.value, () => this.ticks.value], () => this.syncRunCounters(), {
      flush: 'post'
    });
  }

  syncRunCounters() {
    this.fahrenheitRunsShown.value = this.$thermo.runs.fahrenheit;
    this.statusRunsShown.value = this.$thermo.runs.status;
  }

  reRender() {
    this.ticks.value++;
  }
}

export namespace DerivedExample {
  export const $Class = $DerivedExample; // 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 { DerivedExample } from '@examples/derived/DerivedExample';

// Pure wiring: the playground's route model owns the demo — it hosts the
// Thermo, forwards its cells, and mirrors the run counters.
const view = new DerivedExample.Class();

// the state destructure
const {
  // state refs
  celsius,
  ticks,
  fahrenheitRunsShown,
  statusRunsShown,
  // computed refs
  status
} = view;
</script>

<template>
  <DemoBox
    title="Plain getter vs computed(), side by side"
    note="Drag the slider: celsius is a dependency of BOTH, so both bodies run — memoization never skips real dependency changes. Now click re-render: the plain getter re-derives (that's its deal — zero bytes, re-run per render), while the computed body stays frozen. THAT skip is what its ~300 bytes buy."
  >
    <div class="d-vals">
      <div>
        <div class="d-k">celsius</div>
        <div class="d-n">{{ celsius }}&deg;</div>
      </div>
      <div>
        <div class="d-k">fahrenheit &middot; plain getter</div>
        <div class="d-n grad">{{ view.fahrenheit }}&deg;</div>
        <div class="d-mono">body ran {{ fahrenheitRunsShown }}&times;</div>
      </div>
      <div>
        <div class="d-k">status &middot; computed()</div>
        <div class="d-n">{{ status }}</div>
        <div class="d-mono">body ran {{ statusRunsShown }}&times;</div>
      </div>
    </div>
    <div class="d-row">
      <input
        class="d-slider"
        type="range"
        min="-5"
        max="35"
        v-model.number="celsius"
        aria-label="celsius"
      />
      <button class="d-btn" type="button" @click="view.reRender()">{{ view.reRenderLabel }}</button>
    </div>
  </DemoBox>
</template>

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

Released under the MIT License.