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
useMousefor any other source of coordinates and no consumer changes. - Scope-correct teardown. The instance is constructed in setup, so
$mousematerializes 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 ofcanUndo,canRedo,depth,positionLabel.
Related guide pages
- Composables & Stores — the two architectures, the
$-getter, who owns a composable's effects, publishing class logic behinduse(). - 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
// 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
}// 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[];
}
}// 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();
}// 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
}// 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
}<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 · page</div>
<div class="d-n">{{ demo.pageX }}</div>
</div>
<div>
<div class="d-k">y · 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><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.