Vue Reaktivität: Die Fallstricke, die jeden erwischen

Wie Vue 3 Reaktivität intern funktioniert — und warum Destructuring, falsche watch-Targets und Composable-Grenzen immer wieder zu subtilen Bugs führen.

VueReactivityComposablesTypeScript

Wie Vue 3 Reaktivität intern funktioniert

Bevor man die Fallstricke versteht, muss man den Mechanismus verstehen. Vue 3 baut Reaktivität auf ES6 Proxy auf.

Wenn man reactive({ count: 0 }) aufruft, erstellt Vue einen Proxy um das Objekt. Jeder Lesezugriff auf eine Property registriert die aktuelle "Tracking-Instanz" (einen Computed oder einen Watcher) als Abhängigkeit — das ist track. Jeder Schreibzugriff löst alle registrierten Abhängigkeiten aus — das ist trigger.

const state = reactive({ count: 0 })

watchEffect(() => {
  console.log(state.count) // track: dieser Watcher hört auf state.count
})

state.count++ // trigger: Watcher wird neu ausgeführt

ref funktioniert ähnlich, wickelt aber primitive Werte in ein Objekt ein ({ value: T }), damit der Proxy greifen kann. Primitive Werte wie number, string oder boolean haben keine Properties — ohne den Wrapper-Objekt-Trick würde der Proxy nichts bemerken.

Das ist der Grund für .value. Es ist kein willkürliches API-Design, sondern eine technische Notwendigkeit.

Die .value-Falle: Warum Destructuring Reaktivität bricht

Der häufigste Fehler mit ref:

const count = ref(0)
const { value } = count // ← verliert Reaktivität!

watchEffect(() => {
  console.log(value) // feuert nur einmal — beim Erstellen
})

count.value++ // ← kein Re-Render

Nach dem Destructuring hat man eine gewöhnliche Zahl in value — keinen Proxy mehr. Vue kann keine Änderungen beobachten.

Dasselbe gilt für reactive-Objekte:

const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = user // ← beide verlieren Reaktivität

watchEffect(() => {
  console.log(name) // statischer Snapshot, nie aktualisiert
})

user.name = 'Bob' // ← name wird nicht aktualisiert

Das ist keine Vue-Eigenheit — es ist grundlegendes JavaScript. Destructuring kopiert Werte. Reaktivität lebt im Proxy-Objekt, nicht in den kopierten Werten.

Der Fix für Objekte ist toRefs:

const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = toRefs(user) // ← jedes ist jetzt ein Ref

// name.value und age.value reagieren auf Änderungen an user

watch auf reactive Properties — was feuert, was nicht

Ein häufiges Missverständnis: Was genau übergibt man an watch?

const state = reactive({ count: 0 })

// FALSCH: übergibt den Wert (0), nicht die reaktive Source
watch(state.count, (newVal) => {
  console.log(newVal) // feuert nie bei Änderungen
})

// RICHTIG: Getter-Funktion übergeben
watch(() => state.count, (newVal) => {
  console.log(newVal) // feuert korrekt
})

// RICHTIG: für ref direkt die Ref-Instanz
const count = ref(0)
watch(count, (newVal) => {
  console.log(newVal) // feuert korrekt
})

watch erwartet entweder eine Ref-Instanz, ein reaktives Objekt, oder eine Getter-Funktion. Einen primitiven Wert zu übergeben (state.count) ist equivalent zu watch(0, ...) — Vue weiß nicht, woher dieser Wert kommt.

Für tiefe Beobachtung von Objekten:

const user = reactive({ address: { city: 'Wien' } })

// Nur shallow — feuert nicht bei Änderungen an address.city
watch(user, callback)

// Tiefe Beobachtung — feuert bei allen verschachtelten Änderungen
watch(user, callback, { deep: true })

// Oder gezielt auf die Property
watch(() => user.address.city, callback)

watchEffect und das Infinite-Loop-Problem

watchEffect ist elegant: Es führt eine Funktion aus und trackt automatisch alle darin gelesenen reaktiven Abhängigkeiten. Kein manuelles Deklarieren der Sources.

const count = ref(0)
const doubled = ref(0)

watchEffect(() => {
  doubled.value = count.value * 2 // liest count, schreibt doubled
})

Das Problem entsteht, wenn man in derselben watchEffect-Funktion liest und schreibt auf dieselbe reactive Source:

const items = ref<number[]>([])

watchEffect(() => {
  // INFINITE LOOP: liest items.value (→ track) und schreibt items.value (→ trigger)
  items.value = [...items.value, items.value.length]
})

Vue 3 hat Schutzmaßnahmen gegen einige dieser Loops, aber die Grundregel lautet: watchEffect darf auf Dinge schreiben, die es nicht liest. Sobald man auf eine Ref schreibt, die man auch in derselben Funktion liest, ist ein Loop eine reale Gefahr.

In solchen Fällen ist watch mit expliziten Sources die sicherere Wahl, da man präzise kontrolliert, was den Watcher auslöst.

Das Diamond-Dependency-Problem in Computed-Chains

Vue's Computed sind synchron und werden lazy ausgeführt. Das führt in tief verschachtelten Dependency-Graphen zu einem subtilen Timing-Problem.

const a = ref(0)
const b = computed(() => a.value + 1)
const c = computed(() => a.value + 2)
const d = computed(() => b.value + c.value)

watchEffect(() => {
  console.log(d.value) // Wann wird dies ausgeführt?
})

a.value = 10

Beim Diamond-Problem (A → B, A → C, B + C → D) kann es passieren, dass d kurzzeitig mit einem "alten" Wert von b oder c berechnet wird, bevor alle Abhängigkeiten aktualisiert wurden. Vue 3 löst dieses Problem durch den Scheduler: Updates werden gebatcht und in der richtigen Reihenfolge angewendet, bevor der DOM aktualisiert wird.

Das bedeutet auch: Computed-Werte innerhalb von watchEffect sind immer konsistent, weil Vue vor dem Ausführen von Watchern alle ausstehenden Computed-Invalidierungen auflöst.

Das eigentliche Risiko liegt darin, Computed mit Seiteneffekten zu schreiben:

// FALSCH: Computed mit Seiteneffekt
const expensiveData = computed(() => {
  fetchSomething() // ← Seiteneffekt in Computed!
  return a.value * 2
})

Computed dürfen keine Seiteneffekte haben. Sie sind pure Transformationen. Für alles mit Seiteneffekten: watch oder watchEffect.

toRef vs toRefs vs reactive — wann was?

Diese drei werden oft verwechselt. Die Unterschiede:

toRef erstellt eine einzelne Ref aus einer Property eines reaktiven Objekts:

const user = reactive({ name: 'Alice', age: 30 })
const nameRef = toRef(user, 'name')

nameRef.value = 'Bob' // ← aktualisiert user.name
console.log(user.name) // 'Bob'

toRef ist ideal, wenn man eine einzelne Property als Argument an eine Funktion oder Composable übergeben will, die eine Ref erwartet.

toRefs macht dasselbe für alle Properties auf einmal:

const user = reactive({ name: 'Alice', age: 30 })
const { name, age } = toRefs(user) // ← sichere Destructuring

name.value = 'Bob'
console.log(user.name) // 'Bob'

toRefs ist die richtige Wahl, wenn man ein reactive-Objekt aus einem Composable zurückgibt und den Caller ermöglichen will, die Properties zu destrukturieren.

reactive ist für Objekte, bei denen man keine .value überall haben will:

const state = reactive({
  user: null as User | null,
  loading: false,
  error: null as Error | null,
})

// Kein .value nötig
state.loading = true
state.user = fetchedUser

Faustregel: Primitive → ref. Objekte → reactive oder ref (beides funktioniert). Composable-Return-Werte → Refs oder toRefs(reactive(...)).

shallowRef / shallowReactive für Performance

Vue macht standardmäßig tiefes Tracking. Bei großen Datenstrukturen — Arrays mit tausenden Objekten, tiefe State-Trees — kann das zu Performance-Problemen führen.

shallowRef beobachtet nur die .value-Referenz selbst, nicht die Inhalte:

const bigList = shallowRef<Item[]>([])

// Ändert die Referenz → trigger
bigList.value = [...bigList.value, newItem]

// Mutation → kein trigger (Vue bemerkt es nicht)
bigList.value.push(newItem) // ← funktioniert NICHT reaktiv

shallowReactive beobachtet nur die Top-Level-Properties:

const state = shallowReactive({
  user: { name: 'Alice' }, // Änderungen an user.name → kein trigger
  count: 0,                // Änderung an count → trigger
})

Wann shallow sinnvoll ist: bei großen, selten mutierten Datenstrukturen; bei Daten, die man vollständig ersetzt statt schrittweise mutiert; oder bei externen Daten, deren Tiefe man nicht kontrolliert.

triggerRef für manuelle Invalidierung

Manchmal muss man Vue zwingen, Änderungen zu bemerken — zum Beispiel wenn man direkt in ein shallowRef-Objekt mutiert:

const list = shallowRef<Item[]>([])

function addItem(item: Item) {
  list.value.push(item) // Mutation — Vue bemerkt es nicht automatisch
  triggerRef(list)      // Manuell triggern
}

triggerRef ist auch nützlich, wenn man externe Bibliotheken integriert, die den reaktiven State direkt mutieren, ohne dass Vue davon weiß.

Ohne triggerRef würden die Views niemals aktualisiert, obwohl die Daten korrekt wären. Mit triggerRef signalisiert man Vue: "Ich habe etwas geändert — bitte neu rendern."

Composables: Reaktivität über Komponentengrenzen korrekt teilen

Hier liegt eine der häufigsten Architektur-Fallen. Wenn man einen State in einem Composable definiert, muss man entscheiden: soll dieser State global (shared) sein oder pro Komponenteninstanz?

// SHARED STATE: count existiert einmal für die gesamte App
const count = ref(0) // ← außerhalb der Funktion

export function useCounter() {
  return { count }
}

// INSTANZ-STATE: jede Komponente, die useCounter aufruft, bekommt ihren eigenen count
export function useCounter() {
  const count = ref(0) // ← innerhalb der Funktion
  return { count }
}

Das ist keine Vue-Regel, sondern JavaScript-Scoping. Aber es ist ein häufiger Bug: Man erwartet Instanz-State, bekommt aber geteilten State, weil die Ref außerhalb der Funktion definiert wurde.

Für genuinen Shared State in Nuxt: useState:

// Nuxt useState: SSR-sicher, hydration-bewusst
const count = useState('counter', () => 0)

useState ist wie ref, aber mit SSR-Hydration: Der Server-State wird korrekt auf den Client übertragen, ohne dass es zu Hydration-Mismatches kommt — ein Problem, das bei naivem ref außerhalb von Composables auftreten kann.

Reaktivität in Vue ist kein Zauber — es ist ein Tracking-System mit klaren Regeln. Wenn etwas nicht reaktiert, fragt man sich: Liest mein Code den reaktiven Wert durch den Proxy? Wenn nicht, greift das System nicht.