Skip to content

Composables in classes ​

A composable and an ivue class package the same reactive primitives in two containers, and the two meet at one seam that runs in both directions. This page runs both.

Hosting: a composable inside a class ​

Pointer hosts useMouse behind a protected $-getter: the composable is created once, on the first read, and cached for the life of the instance. The public surface is two refs, x and y, plus two plain-getter readouts.

  • The composable is an implementation detail. Swap useMouse for any other source of coordinates and no consumer changes.
  • Scope-correct teardown. The instance is constructed in setup, so $mouse materializes inside the component's scope and its listeners are cleaned up on unmount.

Publishing: a class behind a composable face ​

useUndoHistory() is one line: return new UndoHistory.Class(). A consumer who only knows composables calls it and destructures, and gets a class underneath: lazy state, plain-getter derivations (canUndo, canRedo, positionLabel cost zero bytes per instance), and a model that can be subclassed or swapped without touching a caller.

The demo edits a grocery list. Each operation records one labeled snapshot; undo, redo and the step rail move the history's cursor, and a branch you undo past is dropped by the next operation, the way every editor's history behaves. GroceryList hosts the same UndoHistory class behind a $-getter, which is the hosting direction again, one level up.

  • The face costs one function. The class is the unit; the composable is its calling convention for the ecosystem.
  • Every derivation is a plain getter. A composable version would pay a computed() for each of canUndo, canRedo, depth, positionLabel.
  • Composables & Stores — the two architectures, the $-getter, who owns a composable's effects, publishing class logic behind use().
  • Reactive State — $-prefixed getters as cached containers.
  • Lifecycle & Teardown — why first touch in setup is what ties a composable's listeners to the component.

The source ​

ts
// Pointer.ts — a class HOSTING a composable: private inside, two refs outside.
import type { Ref } from 'vue';
import { Reactive } from '../../ivue';
import { useMouse } from '@vueuse/core';

class $Pointer {
  // the composable is an implementation detail — created once, held forever
  protected get $mouse() {
    return useMouse();
  }

  // the public surface: two refs, FORWARDED from the composable (the
  // annotation says these are the same cells, so a consumer may
  // destructure them)
  get x(): Ref<number> {
    return this.$mouse.x;
  }
  get y(): Ref<number> {
    return this.$mouse.y;
  }

  // display derivations — touch events report fractional page coordinates
  // (23.333…); whole pixels are what a readout wants
  get pageX() {
    return Math.round(this.x.value);
  }
  get pageY() {
    return Math.round(this.y.value);
  }
}

export namespace Pointer {
  export const $Class = $Pointer; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}
ts
// UndoHistory.ts — class logic published behind a composable face
// (useUndoHistory.ts). Consumers who only know composables call
// useUndoHistory() and destructure; the internals are an ivue class:
// lazy state, zero-byte derivations, subclassable, disposable.
//
// A history is a list of labeled SNAPSHOTS and a cursor into it. Every
// operation on the edited thing records a snapshot; undo and redo only
// move the cursor, so the edited thing is always `current.items`.
import { ref, shallowRef } from 'vue';
import { Reactive } from '../../ivue';

class $UndoHistory {
  // MUTABLE STATE — replaced wholesale on every push, never mutated in
  // place, so shallowRef is the right cell.
  get entries() {
    return shallowRef<UndoHistory.Snapshot[]>([{ label: 'start', items: [] }]);
  }
  get cursor() {
    return ref(0);
  }

  // DERIVED — plain getters, zero bytes per instance
  get current() {
    return this.entries.value[this.cursor.value];
  }
  get items() {
    return this.current.items;
  }
  get canUndo() {
    return this.cursor.value > 0;
  }
  get canRedo() {
    return this.cursor.value < this.entries.value.length - 1;
  }
  get depth() {
    return this.entries.value.length;
  }
  get positionLabel() {
    return `${this.cursor.value + 1} / ${this.depth}`;
  }

  /** Whether a history entry is the current one (a per-row template condition). */
  isCurrent(index: number) {
    return index === this.cursor.value;
  }

  /** Whether a history entry is a redo branch past the cursor. */
  isAhead(index: number) {
    return index > this.cursor.value;
  }

  /** Record a new labeled snapshot; anything past the cursor (a redo
   *  branch) is discarded, the way every editor's history behaves. */
  push(label: string, items: readonly string[]) {
    const kept = this.entries.value.slice(0, this.cursor.value + 1);
    this.entries.value = [...kept, { label, items: [...items] }];
    this.cursor.value = kept.length;
  }

  undo() {
    if (this.canUndo) this.cursor.value--;
  }

  redo() {
    if (this.canRedo) this.cursor.value++;
  }

  /** Jump the cursor straight to an entry — clicking a step in the rail. */
  jumpTo(index: number) {
    if (index >= 0 && index < this.depth) this.cursor.value = index;
  }

  clear() {
    this.entries.value = [{ label: 'start', items: [] }];
    this.cursor.value = 0;
  }
}

export namespace UndoHistory {
  export const $Class = $UndoHistory; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop

  /** One recorded step: what was done, and the whole state after it. */
  export interface Snapshot {
    label: string;
    items: readonly string[];
  }
}
ts
// useUndoHistory.ts — the composable face over the UndoHistory class.
//
// One instance per call, like any useX(): a consumer who only knows
// composables uses it without learning anything, and quietly gets the
// class architecture underneath — lazy state, plain-getter derivations,
// a model that can be subclassed (extend UndoHistory.$Class) and swapped
// (reassign UndoHistory.Class) without touching a single caller.
import { UndoHistory } from './UndoHistory';

export function useUndoHistory() {
  return new UndoHistory.Class();
}
ts
// GroceryList.ts — the thing being edited. Every operation is a method
// that records a labeled snapshot in the history it hosts, so undo and
// redo are the history's cursor moving over states this class produced.
import { Reactive } from '../../ivue';
import { UndoHistory } from './UndoHistory';

class $GroceryList {
  // HOSTED composable-shaped model: the history behind a `$`-getter,
  // created on first touch, held for the life of this instance
  protected get $history() {
    return new UndoHistory.Class();
  }

  /** The history, exposed for the template's buttons. */
  get history() {
    return this.$history;
  }

  /** The rail's rows — the history's entries, read as a plain value. */
  get steps() {
    return this.$history.entries.value;
  }

  // DERIVED — the list IS the current snapshot; nothing is stored twice
  get items() {
    return this.$history.items;
  }
  get count() {
    return this.items.length;
  }
  get isEmpty() {
    return this.count === 0;
  }

  /** The pantry the "add" button draws from, in order. */
  get pantry() {
    return ['milk', 'eggs', 'bread', 'apples', 'coffee', 'rice', 'olive oil', 'lemons'];
  }
  get nextItem() {
    return this.pantry[this.count % this.pantry.length];
  }
  get addLabel() {
    return `add ${this.nextItem}`;
  }

  // ACTIONS — each one records a step
  add() {
    const item = this.nextItem;
    this.$history.push(`add ${item}`, [...this.items, item]);
  }

  double() {
    if (this.isEmpty) return;
    this.$history.push(
      'double everything',
      this.items.map((item) => this.doubled(item))
    );
  }

  /** `milk` → `2× milk`, `2× milk` → `4× milk` — the multiplier compounds. */
  doubled(item: string) {
    const match = item.match(/^(\d+)× (.*)$/);
    const quantity = match ? Number(match[1]) : 1;
    const name = match ? match[2] : item;
    return `${quantity * 2}× ${name}`;
  }

  sort() {
    if (this.isEmpty) return;
    this.$history.push('sort A→Z', [...this.items].sort());
  }

  reverse() {
    if (this.isEmpty) return;
    this.$history.push('reverse', [...this.items].reverse());
  }
}

export namespace GroceryList {
  export const $Class = $GroceryList; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
}
ts
// ComposableExample.ts — the route's ONE model. It hosts the two demos
// behind `$`-getters: a class that HOSTS a composable (Pointer over
// useMouse) and a class PUBLISHED as one (GroceryList over the same
// UndoHistory that useUndoHistory() hands a composable consumer).
import { Reactive } from '../../ivue';
import { GroceryList } from './GroceryList';
import { Pointer } from './Pointer';

class $ComposableExample {
  constructor() {
    // First touch INSIDE setup on purpose: useMouse's listeners must land
    // in the component's scope so unmount cleans them up.
    void this.$pointer;
  }

  protected get $pointer() {
    return new Pointer.Class();
  }

  protected get $list() {
    return new GroceryList.Class();
  }

  /** The hosted models, exposed for the template's dotted reads. */
  get pointer() {
    return this.$pointer;
  }
  get list() {
    return this.$list;
  }
  get history() {
    return this.$list.history;
  }
}

export namespace ComposableExample {
  export const $Class = $ComposableExample; // 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 { DemoPointer } from './DemoPointer';

// wiring only — the model hosts the Pointer and the pad-mapping composables
const demo = new DemoPointer.Class();

// the state destructure
const {
  // element refs
  padEl
} = demo;
</script>

<template>
  <DemoBox
    title="A composable, encapsulated"
    note="The component destructures { x, y } from a Pointer instance. useMouse lives inside the class — private, created once on the first read. Consumers see two refs and nothing else."
  >
    <div ref="padEl" class="pad" :class="{ live: demo.inside }">
      <template v-if="demo.inside">
        <div class="hair v" :style="demo.verticalHairStyle" />
        <div class="hair h" :style="demo.horizontalHairStyle" />
        <div class="dot" :style="demo.dotStyle" />
      </template>
      <div v-else class="hint">move the pointer across this pad</div>
    </div>
    <div class="d-vals">
      <div>
        <div class="d-k">x &middot; page</div>
        <div class="d-n">{{ demo.pageX }}</div>
      </div>
      <div>
        <div class="d-k">y &middot; page</div>
        <div class="d-n">{{ demo.pageY }}</div>
      </div>
    </div>
  </DemoBox>
</template>

<style scoped>
.pad {
  position: relative;
  height: 170px;
  margin-bottom: 16px;
  overflow: hidden;
  border-radius: 10px;
  border: 1px solid rgba(148, 163, 184, 0.16);
  background:
    linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
    linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px),
    rgba(255, 255, 255, 0.02);
  background-size:
    24px 24px,
    24px 24px,
    auto;
  transition: border-color 0.25s ease;
}
.pad.live {
  border-color: rgba(99, 102, 241, 0.45);
}
.hair {
  position: absolute;
  background: rgba(99, 102, 241, 0.35);
  pointer-events: none;
}
.hair.v {
  top: 0;
  bottom: 0;
  width: 1px;
}
.hair.h {
  left: 0;
  right: 0;
  height: 1px;
}
.dot {
  position: absolute;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  background: radial-gradient(circle, #34d399 0%, #6366f1 80%);
  box-shadow: 0 0 14px rgba(99, 102, 241, 0.8);
  pointer-events: none;
}
.hint {
  display: grid;
  place-items: center;
  height: 100%;
  font-size: 13px;
  color: #64748b;
}
</style>
vue
<script setup lang="ts">
import DemoBox from './DemoBox.vue';
import { GroceryList } from '@examples/composable/GroceryList';

// GroceryList hosts an UndoHistory — the same class useUndoHistory()
// hands a composable consumer — and records one labeled step per
// operation. Undo, redo and the rail only move the history's cursor.
const list = new GroceryList.Class();
</script>

<template>
  <DemoBox
    title="A class, published as a composable"
    note="The undo history is an ivue class behind a one-line useUndoHistory() face. Every operation records a labeled snapshot; undo, redo and the step rail move its cursor. A branch you undo past is struck through and dropped by the next operation, the way every editor's history behaves."
  >
    <div class="d-row">
      <button class="d-btn primary" type="button" @click="list.add()">{{ list.addLabel }}</button>
      <button class="d-btn" type="button" :disabled="list.isEmpty" @click="list.double()">
        double
      </button>
      <button class="d-btn" type="button" :disabled="list.isEmpty" @click="list.sort()">
        sort
      </button>
      <button class="d-btn" type="button" :disabled="list.isEmpty" @click="list.reverse()">
        reverse
      </button>
    </div>
    <div class="d-row">
      <button
        class="d-btn"
        type="button"
        :disabled="!list.history.canUndo"
        @click="list.history.undo()"
      >
        Undo
      </button>
      <button
        class="d-btn"
        type="button"
        :disabled="!list.history.canRedo"
        @click="list.history.redo()"
      >
        Redo
      </button>
      <span class="d-mono">{{ list.history.positionLabel }}</span>
    </div>
    <ol class="u-rail">
      <li
        v-for="(entry, index) in list.steps"
        :key="index"
        class="u-step"
        :class="{ current: list.history.isCurrent(index), ahead: list.history.isAhead(index) }"
        @click="list.history.jumpTo(index)"
      >
        {{ entry.label }}
      </li>
    </ol>
    <div class="u-label">Result:</div>
    <div class="u-list">
      <span v-if="list.isEmpty" class="d-mono">empty — add something</span>
      <span v-for="(item, index) in list.items" :key="index" class="u-chip">{{ item }}</span>
    </div>
  </DemoBox>
</template>

<style scoped>
.u-label {
  margin: 14px 0 0;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--vp-c-text-2);
}
.u-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  min-height: 34px;
  margin: 6px 0 0;
}
.u-chip {
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--vp-c-divider);
  font-size: 13px;
}
.u-rail {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.u-step {
  /* VitePress styles every `li` in a doc page (margin, line-height) —
     the rail's steps are pills, so pin both */
  margin: 0;
  padding: 2px 9px;
  border-radius: 6px;
  border: 1px solid var(--vp-c-divider);
  font-size: 12px;
  line-height: 1.4;
  color: var(--vp-c-text-2);
  cursor: pointer;
}
.u-step.current {
  border-color: var(--ivue-link-2);
  color: var(--vp-c-text-1);
  font-weight: 600;
}
.u-step.ahead {
  opacity: 0.45;
  text-decoration: line-through;
}
</style>

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

Released under the MIT License.