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:
celsiusis 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.
Related guide pages
- Computed & Watch — when
computed()earns its bytes; plainwatchversus$watch. - Performance by Design — what the shape costs and does not.
- Reactive State — ref-getters, plain getters, the
$-prefixed cache.
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 }}°</div>
</div>
<div>
<div class="d-k">fahrenheit · plain getter</div>
<div class="d-n grad">{{ view.fahrenheit }}°</div>
<div class="d-mono">body ran {{ fahrenheitRunsShown }}×</div>
</div>
<div>
<div class="d-k">status · computed()</div>
<div class="d-n">{{ status }}</div>
<div class="d-mono">body ran {{ statusRunsShown }}×</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.