Reactive State
In ivue you declare state as getters that return refs. That single convention is what makes instances plain and creation lazy.
ref
class $Box {
get width() {
return ref(100)
}
get tags() {
return ref<string[]>([])
}
}
const Box = Reactive($Box)
const box = new Box()
box.width.value // 100
box.width.value = 250 // write
box.tags.value.push('a')The getter runs once per instance; the same ref is returned forever after.
shallowRef
Use shallowRef for large structures you replace rather than mutate deeply:
get rows() {
return shallowRef<Row[]>([])
}
// ...
instance.rows.value = nextRows // triggers; deep mutations do notDerived values: plain getters first
Derive with a plain getter by default. No computed():
class $Box {
get width() {
return ref(4)
}
get height() {
return ref(3)
}
get area() {
return this.width.value * this.height.value // plain getter
}
}It stays fully reactive: whatever effect reads area — a render, a watcher — reads width and height underneath and subscribes to them directly. Change a source and the effect re-runs.
Why this is the default:
- Zero memory per instance. A plain getter lives once, on the prototype. An ivue
computed()materializes only when that getter is first read, then costs ~300 bytes on that instance. Sixty observed computeds on 10k instances is real megabytes — see Memory. - Zero staleness. The value re-derives whenever it's read. Nothing to invalidate, nothing to reason about.
- The engine helps. On first access it sees a non-ref result and restores a native prototype getter — from then on it's ordinary JavaScript.
computed(): the surgical opt-in
Wrap a getter in computed() when the memoization earns its bytes:
get sortedRows() {
return computed(() => [...this.rows.value].sort(byScore)) // expensive: memoize
}Reach for it when:
- the derivation is genuinely expensive (sorting/filtering large arrays);
- an unchanged result should suppress re-renders (Vue 3.4+ computeds stop propagation on equal values, plain getters cannot);
- you need a stable ref identity to hand to
watch, a prop, or a composable.
Read it with .value — standard Vue computed semantics.
Writable computed
Return a computed with get/set to make a two-way derived value:
get celsius() {
return ref(20)
}
get fahrenheit() {
return computed({
get: () => this.celsius.value * 9 / 5 + 32,
set: (fahrenheit: number) => {
this.celsius.value = ((fahrenheit - 32) * 5) / 9
},
})
}
// instance.fahrenheit.value = 100 → updates celsiusMethods
Plain methods just work. They're bound to the instance, so this is always correct — even when detached:
class $Box {
get width() {
return ref(1)
}
grow() {
this.width.value++
}
}
const Box = Reactive($Box)
const box = new Box()
const { grow } = box // detached
grow() // still updates box.width
box.grow === box.grow // true — referentially stablePlain (non-reactive) getters
A getter that returns a non-ref value is fine — ivue detects it on first access and turns it back into a normal getter (zero overhead, see Fundamental Principles):
get kind() {
return 'box' // just a normal getter
}$-prefixed singletons
A getter whose name starts with $ is cached whole, forever on first access — ideal for "create this composable/service once per instance":
import { useMouse } from '@vueuse/core'
class $Pointer {
get $mouse() {
return useMouse() // created once, reused
}
get x() {
return this.$mouse.x
}
get y() {
return this.$mouse.y
}
}"Forever" means for that instance's owned lifetime. Component-scoped instances need no explicit model teardown: Vue stops setup-owned effects on unmount, and the instance becomes collectible when nothing else retains it. The owner of a component-outliving model calls $stopEffects(), which clears these caches and the instance's watchers.
Private fields
Regular class privates work as encapsulated, non-reactive instance state:
class $Cache {
#hits = 0
get value() {
return ref(0)
}
read() {
this.#hits++
return this.value.value
}
}Native #private fields work in development, tests, SSR, and production. JavaScript brand-checks them against their exact class declaration. Vue owner reconstruction after a script edit creates the new declaration and its matching brand together, so ivue needs no private-field detection or alternate update path.
Keyed reactivity: the third shape
Ref-getters express named state — members you can list when you author the class. shallowRef expresses wholesale-replaced structures. There is a third shape the getter syntax cannot reach: keyed state — sparse, unbounded, indexed by ids or coordinates unknown until runtime (cells of a sheet, rows of a stream, entities by id). You cannot write a getter per key.
The pattern: hold collections of reactive primitives as plain values, and materialize them per observation.
class $Sheet {
// Plain readonly fields — the COLLECTIONS aren't reactive; their VALUES are.
private readonly cellVersions = new Map<number, Ref<number>>();
/** READ path: get-OR-CREATE, then subscribe — observation materializes. */
private trackCell(cellKey: number): void {
let versionRef = this.cellVersions.get(cellKey);
if (!versionRef) {
versionRef = ref(0);
this.cellVersions.set(cellKey, versionRef);
}
void versionRef.value; // subscribes whatever effect is currently running
}
/** WRITE path: PEEK-ONLY — unobserved keys allocate nothing, notify no one. */
private bumpCell(cellKey: number): void {
const versionRef = this.cellVersions.get(cellKey);
if (versionRef) versionRef.value++;
}
}The read/write asymmetry is the pattern: reads get-or-create (so cost is priced by observation), while writes to unobserved keys allocate no signal. Four rules keep it honest:
- Ground truth lives in plain storage (typed arrays, Maps). The refs are version signals, not value holders — bump to invalidate, let readers re-derive from ground truth.
- Cached computeds per key follow the same shape (
Map<key, ComputedRef>), with bodies that delegate to methods and an explicit release path — keyed overlays cannot garbage-collect on their own (the Map holds strong references; attached watchers subscribe permanently), so eviction is part of the design, not an afterthought. - Coarse tiers are the same pattern at lower resolution: one ref covering many keys — a 4,096-row block, or a single version counter for a whole collection — for subscribers that span many keys. One integer where naive design would put a million nodes.
- No wrapper needed.
ref()/computed()are first-class values from@vue/reactivity; storing them in Maps inside aReactive()class composes with everything — methods stay bound and$watchworks.
| state shape | expression |
|---|---|
| named members | get x() { return ref(v) } |
| wholesale-replaced structure | get rows() { return shallowRef<Row[]>([]) } |
| keyed / sparse / unbounded | Map<key, Ref> + get-or-create track, peek-only bump |
Each shape is the same invariant at a different granularity — nothing exists until observed: getters price members, keyed collections price keys. Proven at scale in the Flyweight Pattern: 20,000,000 live formula-capable cells at 4.7 bytes each, with the overlay evicted as observation moves.