Skip to content

Reactive State ​

In ivue you declare state as getters that return refs. That single convention is what makes instances plain and creation lazy.

The refs themselves are Vue 3's own: ref, shallowRef, and computed here are the same functions you import from vue everywhere else — ivue invents no reactivity machinery of its own. The engine only decides where those refs live (cached per instance, materialized on first read). That's the beauty of the design: everything you know about Vue reactivity — and everything Vue ships next — applies unchanged.

ref ​

ts
import { ref } from 'vue'

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.

Publish real classes through the namespace pattern

const Box = Reactive($Box) above is a one-off, compressed so the state mechanics stay in focus. In a real module, a class ships through the namespace pattern — it is what makes the architecture immune by construction: a raw anchor children extends, a mutable Class slot tests and kernels can swap, and a typed unwrapping surface.

ts
class $Box {
  get width() {
    return ref(100)
  }
}

export namespace Box {
  export const $Class = $Box // raw — children `extends` this
  export let Class = Reactive($Class) // reactive — you `new` this
  // the type of every unwrapping surface (defineExpose, reactive())
  export type Instance = typeof Class.Instance
}

const box = new Box.Class()

Every guide example that compresses the export is shorthand for this form.

shallowRef ​

Use shallowRef for large structures you replace rather than mutate deeply:

ts
get rows() {
  return shallowRef<Row[]>([])
}
// ...
instance.rows.value = nextRows   // triggers; deep mutations do not

Derived values: plain getters first ​

Derive with a plain getter by default. No computed():

ts
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.
Refs behind getters, derived by a plain getterLive · runs the shipped engine
width
120
height
80
area · plain getter
9,600
width and height are refs cached per instance. area is a plain getter that reads them, so it stays current with zero allocation.

computed(): the surgical opt-in ​

Wrap a getter in computed() when the memoization earns its bytes:

ts
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:

ts
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 celsius

Methods ​

Plain methods just work. They're bound to the instance, so this is always correct — even when detached:

ts
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 stable

Plain (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):

ts
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":

ts
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:

ts
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.

This section documents what the engine SUPPORTS. What the standard recommends is stricter: internal members are protected, never private or #private — both hide the member from subclasses, and a subclass that cannot reach a member can only fork the file. The reasoning is Ban private.

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.

ts
class $Sheet {
  // Plain readonly fields — the COLLECTIONS aren't reactive; their VALUES are.
  protected readonly cellVersions = new Map<number, Ref<number>>();

  /** READ path: get-OR-CREATE, then subscribe — observation materializes. */
  protected 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. */
  protected 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 a Reactive() class composes with everything — methods stay bound and $watch works.
state shapeexpression
named membersget x() { return ref(v) }
wholesale-replaced structureget rows() { return shallowRef<Row[]>([]) }
keyed / sparse / unboundedMap<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.

See it running ​

Released under the MIT License.