Custom Refs in Vue 3 selbst bauen: customRef, track und trigger
AI generated
<v/>
{ }
Vue.js · Reaktivität · Composition API · customRef
Custom Refs in Vue 3 selbst bauen
customRef, track und trigger im Detail

Ein Custom Ref gibt vollständige Kontrolle über Dependency Tracking und Update-Auslösung. Wer die customRef API versteht, kann Debounce, Validierung, Storage-Synchronisation und andere Muster als sauberes, wiederverwendbares reaktives Primitiv kapseln, statt sie in jeder Komponente neu zusammenzuklicken.

18 Min. Lesezeit customRef · track · trigger · Composables Vue 3.4+ · TypeScript · Vitest

1. Warum ein Custom Ref überhaupt bauen

Ein Custom Ref ist ein reaktives Primitiv, das sich wie ein normales ref() verhält, dessen Dependency Tracking und Update-Auslösung aber vollständig selbst kontrolliert werden. Standardmäßig koppelt Vue bei ref() das Lesen automatisch an track() und jedes Schreiben automatisch an trigger(). Ein Custom Ref durchbricht diese feste Kopplung: Man entscheidet selbst, wann eine Abhängigkeit registriert wird und wann eine Komponente ein Update erhält.

Das klingt zunächst nach einem Nischenfall, ist in der Praxis aber der saubere Weg, um Muster wie Debounce, Validierung mit Rollback oder Synchronisation mit externen Datenquellen zu kapseln. Ohne Custom Ref landet diese Logik oft verstreut in watch()-Callbacks und lokalen Timern, direkt in der Komponente. Mit einem Custom Ref wird dieselbe Logik zu einer testbaren, wiederverwendbaren Funktion, die sich anfühlt wie ein gewöhnlicher reaktiver Wert.

Der Vorteil zeigt sich besonders in Composables. Eine Funktion, die einen Custom Ref zurückgibt, versteckt die interne Komplexität komplett vor dem Aufrufer. Die Komponente schreibt myValue.value = x wie bei jedem anderen Ref, merkt aber nichts davon, dass im Hintergrund ein Debounce-Timer läuft oder ein Netzwerk-Request abgesetzt wird. Diese Kapselung ist der eigentliche Wert eines eigenen Custom Ref.

2. Die customRef API im Detail: track und trigger

Die Funktion customRef() erwartet eine Factory-Funktion, die zwei Parameter erhält: track und trigger. Diese Factory muss ein Objekt mit get() und set(value) zurückgeben, ähnlich wie ein JavaScript-Property-Descriptor. In get() ruft man track() auf, um Vue mitzuteilen, dass hier eine reaktive Abhängigkeit gelesen wird. In set() ruft man nach der eigentlichen Wertänderung trigger() auf, um alle abhängigen Effekte neu auszuführen.

Der entscheidende Unterschied zu einem normalen ref(): Zwischen dem Setzen des internen Werts und dem Aufruf von trigger() kann beliebige Logik stehen, inklusive asynchroner Verzögerung. Genau das macht ein Custom Ref zum passenden Werkzeug für Debounce. Ein normales ref() löst trigger() synchron und sofort bei jeder Zuweisung aus, ein Custom Ref kann diesen Zeitpunkt frei bestimmen.

Wichtig ist, dass track() bei jedem get()-Aufruf ausgeführt werden muss, sonst bemerkt Vue nicht, dass eine Komponente von diesem Wert abhängt, und Updates bleiben aus. Wird trigger() vergessen, sieht die Komponente niemals eine Änderung, obwohl der interne Wert korrekt aktualisiert wurde. Beide Aufrufe sind also keine Optionen, sondern Pflichtbestandteil jedes funktionierenden Custom Ref.


import { customRef } from 'vue'

// Minimal custom ref: behaves exactly like ref(), fully manual
function myRef(initialValue) {
  let value = initialValue
  return customRef((track, trigger) => {
    return {
      get() {
        track() // register this read as a dependency
        return value
      },
      set(newValue) {
        value = newValue
        trigger() // notify all dependent effects
      }
    }
  })
}

const count = myRef(0)
console.log(count.value) // 0
count.value = 5 // triggers re-render of anything reading count.value

3. Ein Custom Ref für Debounced Search Input

Das klassische Einsatzgebiet für einen Custom Ref ist ein Sucheingabefeld, das nicht bei jedem Tastendruck einen Request auslösen soll. Statt den Debounce in der Komponente mit watch() und setTimeout zu implementieren, kapselt man ihn direkt im Custom Ref. Die Komponente bindet das Ref ganz normal mit v-model, das Debounce-Verhalten bleibt intern und unsichtbar.

Der Trick liegt darin, dass set() den internen Wert sofort aktualisiert, damit das Eingabefeld selbst ohne Verzögerung reagiert, aber trigger() erst nach Ablauf des Timers aufgerufen wird. So bleibt das Textfeld responsiv, während alle abhängigen Effekte, etwa ein API-Aufruf in einem watch(), erst nach der Debounce-Zeit ausgelöst werden. Das ist ein Vorteil, den man mit reinem watch()-Debounce nur mit zusätzlichem lokalem State nachbilden könnte.


import { customRef } from 'vue'

// Custom ref that delays the trigger, not the internal value
export function useDebouncedRef(initialValue, delay = 300) {
  let value = initialValue
  let timeout

  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      value = newValue
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        trigger() // fires only after the user stops typing
      }, delay)
    }
  }))
}

// In a component:
// const searchTerm = useDebouncedRef('')
// watch(searchTerm, (term) => fetchResults(term))

4. Custom Ref mit Validierung und Rollback

Ein weiteres starkes Einsatzfeld für einen Custom Ref ist Validierung mit automatischem Rollback bei ungültigen Werten. Statt in jeder Komponente manuell zu prüfen, ob ein Eingabewert erlaubt ist, kapselt man diese Regel im set() des Custom Ref. Ist der neue Wert ungültig, wird er einfach verworfen und trigger() nicht aufgerufen, der alte Wert bleibt sichtbar bestehen.

Diese Technik eignet sich besonders für numerische Eingaben mit Grenzwerten, etwa Mengenfelder in einem Warenkorb, die niemals negativ werden dürfen. Der Custom Ref kann zusätzlich einen Fehlerzustand als separaten reaktiven Wert exponieren, sodass die Komponente eine Fehlermeldung anzeigen kann, ohne die Validierungslogik selbst zu kennen. Das hält Komponenten schlank und die Validierungsregel an einer einzigen Stelle wiederverwendbar.


import { customRef, ref } from 'vue'

// Custom ref with validation and automatic rollback on invalid input
export function useValidatedNumber(initialValue, { min = 0, max = Infinity } = {}) {
  let value = initialValue
  const error = ref(null)

  const numberRef = customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      const parsed = Number(newValue)
      if (Number.isNaN(parsed) || parsed < min || parsed > max) {
        error.value = `Wert muss zwischen ${min} und ${max} liegen`
        return // rollback: internal value stays unchanged, no trigger
      }
      error.value = null
      value = parsed
      trigger()
    }
  }))

  return { value: numberRef, error }
}

5. Custom Ref mit localStorage synchronisieren

Ein Custom Ref eignet sich hervorragend, um reaktiven State transparent mit localStorage zu synchronisieren. Beim Lesen liefert get() den aktuellen In-Memory-Wert, beim Schreiben aktualisiert set() sowohl den internen Wert als auch den Eintrag im Browser-Storage. Aus Sicht der Komponente sieht das Ref aus wie jeder andere reaktive Wert, die Persistenz passiert vollständig transparent im Hintergrund.

Interessant wird es, wenn man zusätzlich auf das storage-Event des Browsers hört, das bei Änderungen in einem anderen Tab feuert. Der Custom Ref kann dann bei einem externen Storage-Event trigger() aufrufen, ohne dass set() lokal aufgerufen wurde, und so Cross-Tab-Synchronisation komplett innerhalb des Refs kapseln. Das ist ein Muster, das mit einem normalen ref() nicht sauber abbildbar wäre, weil dort kein Kontrollpunkt für externe Trigger existiert.


import { customRef } from 'vue'

// Custom ref synced with localStorage, including cross-tab updates
export function useStorageRef(key, initialValue) {
  let value = localStorage.getItem(key)
    ? JSON.parse(localStorage.getItem(key))
    : initialValue

  let triggerFn

  window.addEventListener('storage', (event) => {
    if (event.key === key && triggerFn) {
      value = JSON.parse(event.newValue)
      triggerFn() // sync value changed in another browser tab
    }
  })

  return customRef((track, trigger) => {
    triggerFn = trigger
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        value = newValue
        localStorage.setItem(key, JSON.stringify(newValue))
        trigger()
      }
    }
  })
}

6. Custom Refs typisieren mit TypeScript

In TypeScript profitiert ein Custom Ref stark von generischen Typparametern. customRef<T>() erlaubt es, den zurückgegebenen Wert exakt zu typisieren, sodass ein Aufrufer korrekte Autovervollständigung erhält, genau wie bei einem normalen Ref<T>. Der Rückgabetyp der Factory-Funktion muss dem internen CustomRefFactory<T>-Interface entsprechen, das get(): T und set(value: T): void vorschreibt.

Ein häufiger Fehler ist, den generischen Typparameter wegzulassen und Vue den Typ aus dem Initialwert inferieren zu lassen. Das funktioniert bei einfachen Primitiven, scheitert aber bei komplexeren Union-Typen oder wenn der Custom Ref zunächst null enthält und später einen konkreten Wert annimmt. Explizite Typisierung mit customRef<string | null>() vermeidet solche Überraschungen und macht die API-Signatur des Composables selbstdokumentierend.


import { customRef, type Ref } from 'vue'

interface DebouncedRefOptions {
  delay?: number
}

// Explicit generic typing for a reusable custom ref factory
export function useDebouncedRef<T>(
  initialValue: T,
  options: DebouncedRefOptions = {}
): Ref<T> {
  const { delay = 300 } = options
  let value = initialValue
  let timeout: ReturnType<typeof setTimeout>

  return customRef<T>((track, trigger) => ({
    get(): T {
      track()
      return value
    },
    set(newValue: T) {
      value = newValue
      clearTimeout(timeout)
      timeout = setTimeout(() => trigger(), delay)
    }
  }))
}

7. Custom Refs mit Vitest testen

Ein Custom Ref lässt sich isoliert testen, ohne eine Vue-Komponente zu mounten, da customRef() auch außerhalb des Component-Setups gültig einsetzbar ist. Für Tests mit zeitbasierter Logik wie Debounce ist vi.useFakeTimers() unverzichtbar: Ohne gefälschte Timer müsste ein Test real 300 Millisekunden warten, mit ihnen kann man die Zeit deterministisch vorspulen.

Beim Testen eines Custom Ref prüft man typischerweise drei Dinge: dass .value synchron den erwarteten internen Zustand liefert, dass reaktive Effekte, die das Ref beobachten, korrekt und zum richtigen Zeitpunkt auslösen, und dass ungültige Eingaben bei Validierungs-Refs tatsächlich zu einem Rollback führen. Ein watchEffect() im Test, kombiniert mit einem Spy, macht sichtbar, wie oft und wann trigger() tatsächlich gefeuert hat.


import { describe, it, expect, vi } from 'vitest'
import { watchEffect } from 'vue'
import { useDebouncedRef } from './useDebouncedRef'

describe('useDebouncedRef', () => {
  it('delays trigger until after the debounce window', () => {
    vi.useFakeTimers()
    const debounced = useDebouncedRef('', 300)
    const spy = vi.fn()

    watchEffect(() => {
      spy(debounced.value)
    })

    debounced.value = 'a'
    debounced.value = 'ab'
    debounced.value = 'abc'

    expect(spy).toHaveBeenCalledTimes(1) // only the initial run so far

    vi.advanceTimersByTime(300)
    expect(spy).toHaveBeenCalledTimes(2) // trigger fired once, latest value
    expect(debounced.value).toBe('abc')

    vi.useRealTimers()
  })
})

8. Häufige Fehler beim Bau eigener Refs

Der häufigste Fehler bei einem selbst gebauten Custom Ref ist, track() zu vergessen oder es nur bedingt aufzurufen. Wird track() etwa nur innerhalb eines if-Blocks ausgeführt, registriert Vue die Abhängigkeit nur manchmal, und Updates verschwinden scheinbar zufällig. track() muss bei jedem get()-Aufruf bedingungslos laufen, ganz gleich, welcher Wert zurückgegeben wird.

Ein zweiter typischer Fehler ist, den internen Wert eines Custom Ref außerhalb der Factory-Funktion in einem gemeinsam genutzten Modul-Scope zu speichern. Wird derselbe Composable mehrfach in verschiedenen Komponenten aufgerufen, teilen sich dann versehentlich alle Instanzen denselben Zustand, weil die Closure nicht pro Aufruf neu erzeugt wird. Jeder Aufruf der Factory-Funktion muss eine eigene, unabhängige Closure-Variable für den internen Wert anlegen.

9. Custom Ref im Vergleich zu Alternativen

Nicht jedes reaktive Muster erfordert einen eigenen Custom Ref. Die folgende Übersicht zeigt, wann ein Custom Ref die richtige Wahl ist und wann einfachere Bordmittel ausreichen.

Anforderung Ohne Custom Ref Mit Custom Ref Vorteil
Debounce bei v-model watch + setTimeout in Komponente useDebouncedRef() Wiederverwendbar, Komponente bleibt schlank
Feldvalidierung mit Rollback Manuelle Prüfung vor jeder Zuweisung Validierung in set() Ungültige Werte nie sichtbar
Cross-Tab-Sync Eigene Event-Listener pro Komponente Custom Ref mit storage-Event Sync-Logik an einer Stelle gekapselt
Einfacher lokaler Wert ref() Unnötiger Overhead customRef nur bei echtem Kontrollbedarf
Read-only Ableitung Custom Ref mit leerem set() computed() Passenderes, einfacheres Primitiv

Die Faustregel ist einfach: Ein Custom Ref lohnt sich immer dann, wenn zwischen dem Setzen eines Werts und dem Auslösen des Updates zusätzliche Logik nötig ist, sei es Zeitverzögerung, Validierung oder externe Synchronisation. Für alle Fälle, in denen Lesen und Schreiben direkt gekoppelt bleiben können, ist ein normales ref() die einfachere und genauso performante Wahl.

Mironsoft

Vue 3, Nuxt und Composition API Beratung

Reaktivität in Vue 3 wird bei euch zum Bugmagneten?

Wir analysieren eure Composables, bauen eigene Custom Refs für Debounce, Validierung und Storage-Sync und bringen eure Vue-3-Reaktivität auf ein wartbares Fundament.

Composable-Audit

Bestehende Refs und watch-Logik auf saubere Kapselung prüfen

Custom-Ref-Bibliothek

Debounce, Validierung und Storage-Sync als getestete Composables

TypeScript-Typisierung

Generische Refs mit korrekter Typinferenz für euer Team

10. Zusammenfassung

Ein Custom Ref ist das richtige Werkzeug, wenn zwischen dem Setzen eines Werts und dem Auslösen reaktiver Updates zusätzliche Logik nötig ist. Die customRef()-API mit ihren Parametern track und trigger gibt vollständige Kontrolle über Dependency Tracking und Update-Zeitpunkt. Debounce, Validierung mit Rollback und Storage-Synchronisation sind die drei häufigsten praktischen Anwendungsfälle, in denen ein eigener Custom Ref Code deutlich sauberer macht als verstreute watch()-Logik in der Komponente.

In TypeScript profitieren Custom Refs von expliziten generischen Typparametern, und beim Testen lassen sie sich isoliert mit gefälschten Timern prüfen, ohne eine komplette Komponente zu mounten. Wer track() und trigger() konsequent korrekt aufruft und den internen Zustand pro Factory-Aufruf sauber kapselt, erhält reaktive Primitive, die sich für den Aufrufer nicht anders anfühlen als ein gewöhnliches ref(), intern aber deutlich mehr können.

Custom Refs in Vue 3 — Das Wichtigste auf einen Blick

customRef API

Factory mit track und trigger als Parametern, Rückgabe von get()/set() wie ein Property-Descriptor.

Typische Anwendungsfälle

Debounce, Validierung mit Rollback, localStorage-Sync inklusive Cross-Tab-Updates.

Häufigster Fehler

track() bedingt oder gar nicht aufrufen — Updates bleiben dann scheinbar zufällig aus.

Testen

Isoliert außerhalb von Komponenten möglich, mit vi.useFakeTimers() für zeitbasierte Refs.

11. FAQ: Custom Refs in Vue 3

1Was ist ein Custom Ref?
Ein mit customRef() erzeugtes reaktives Primitiv mit manuellem track()/trigger() statt automatischer Kopplung wie bei ref().
2Wann Custom Ref statt ref()?
Wenn zwischen Setzen und Trigger zusätzliche Logik nötig ist: Debounce, Validierung mit Rollback, externe Synchronisation.
3Was wenn track() fehlt?
Vue registriert die Abhängigkeit nicht, Updates bleiben scheinbar zufällig aus.
4Rollback ohne trigger()?
Ja, genau das Muster für Validierung: bei ungültiger Eingabe bleibt der alte Wert bestehen, kein trigger().
5Custom Ref mit Debounce bauen?
set() aktualisiert sofort, setTimeout ruft trigger() erst nach Ablauf der Verzögerung auf.
6Typisierung in TypeScript?
customRef() mit get(): T und set(value: T): void, explizite Typen vermeiden Inferenz-Fehler.
7Debounce-Logik testen?
vi.useFakeTimers() kombiniert mit watchEffect() und Spy, um Trigger-Zeitpunkte zu prüfen.
8Mehrere Komponenten synchronisieren?
Ja, bei gemeinsamem Modul-Scope für die Closure-Variable und geteilter Ref-Instanz.
9Performanter als watch()?
Marginal, der Vorteil liegt in Kapselung und Wiederverwendbarkeit, nicht in roher Performance.
10Ersetzt es Reactivity Transform?
Nein, unabhängige Konzepte: customRef() ist stabile Runtime-API, Reactivity Transform war experimentelle Compiler-Syntax.