statt Prop Drilling oder globale Stores
Vue provide/inject ist der unterschätzte Mittelweg zwischen Prop Drilling und globalen Pinia-Stores. Richtig eingesetzt ermöglicht es scoped Dependency Injection für Komponentenfamilien – typsicher, reaktiv und ohne globalen Zustand.
Inhaltsverzeichnis
- 1. Das Problem mit Prop Drilling und globalen Stores
- 2. provide und inject: Grundlagen und Scope
- 3. InjectionKey: Typsicherheit mit TypeScript
- 4. Reaktive Werte bereitstellen und schützen
- 5. provide/inject in Composables kapseln
- 6. Plugin-Pattern: App-weites provide
- 7. Readonly-Wrapping und Mutationsschutz
- 8. Wann provide/inject, wann Pinia, wann Props?
- 9. Kommunikationsmuster im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit Prop Drilling und globalen Stores
Prop Drilling entsteht, wenn Daten über mehrere Komponentenebenen als Props weitergereicht werden müssen, obwohl nur die tiefste Komponente sie tatsächlich benötigt. Die Zwischenschichten kennen die Daten, nutzen sie aber nicht – sie dienen nur als Durchreiche. Das führt dazu, dass eine Änderung der Datenstruktur alle Zwischenkomponenten betrifft, obwohl diese keine inhaltliche Verantwortung für die Daten tragen. In einem komplexen Komponentenbaum mit fünf oder mehr Ebenen wird Prop Drilling schnell zur Wartungsbelastung: Jede neue Ebene muss die Props deklarieren und weiterreichen, selbst wenn sie nichts damit anfängt.
Die naheliegende Alternative – ein globaler Pinia-Store – löst das Problem technisch, aber mit einem Preis: Der Zustand wird global verfügbar, was bedeutet, dass jede Komponente in der gesamten App ihn lesen und mutieren kann. Das ist für wirklich globalen Zustand (Nutzerauthentifizierung, Theme, Sprache) richtig. Für Zustand, der nur innerhalb einer bestimmten Komponentenfamilie relevant ist – zum Beispiel der Zustand eines mehrstufigen Formulars, eines Accordion-Panels oder einer komplexen Tabellen-Komponente – ist ein globaler Store überdimensioniert und führt zu unnötiger Kopplung. Vue provide/inject ist der richtige Mittelweg: scoped Dependency Injection, die nur innerhalb des Komponentenbaums unterhalb der providenden Komponente sichtbar ist.
2. provide und inject: Grundlagen und Scope
Die Funktionen provide und inject sind seit Vue 3 Teil der Composition API und arbeiten auf Basis des Komponentenbaums. Eine Komponente, die provide(key, value) aufruft, macht diesen Wert für alle Nachfahren-Komponenten verfügbar – unabhängig davon, wie tief sie im Baum stecken. Jede Nachfahren-Komponente kann inject(key) aufrufen und den bereitgestellten Wert empfangen, ohne dass alle Zwischenschichten ihn als Prop durchreichen müssen. Der Scope ist dabei immer lokal zum Teilbaum unterhalb der providenden Komponente – andere Teile der App sehen den Wert nicht.
Ein entscheidender Unterschied zu globalen Stores: Provide/inject ist komponentengebunden. Wenn die providende Komponente aus dem DOM entfernt wird, verschwindet auch der bereitgestellte Wert – Kindkomponenten die inject aufrufen erhalten dann den Defaultwert oder undefined. Das macht provide/inject ideal für Zustand, der an den Lebenszyklus einer bestimmten Komponente gebunden ist: Ein mehrstufiges Formular stellt seinen Zustand bereit, alle Schritte injizieren ihn, und wenn das Formular unmounted wird, ist der Zustand weg – kein Aufräumen notwendig, keine globale Store-Aktion um den Zustand zurückzusetzen.
// components/MultiStepForm.vue — provides form state to all child steps
import { provide, ref, readonly } from 'vue'
import { FORM_STATE_KEY } from '~/injection-keys'
const currentStep = ref(1)
const formData = ref<FormData>({})
const totalSteps = 3
// Provide reactive state + mutation method to all descendants
provide(FORM_STATE_KEY, {
currentStep: readonly(currentStep), // read-only for children
formData: readonly(formData),
totalSteps,
nextStep: () => { currentStep.value++ },
prevStep: () => { currentStep.value-- },
updateData: (data: Partial<FormData>) => {
formData.value = { ...formData.value, ...data }
},
})
3. InjectionKey: Typsicherheit mit TypeScript
Ohne InjectionKey ist inject typunsicher: Der Rückgabetyp ist unknown, was bedeutet, dass TypeScript keine Autovervollständigung für den injizierten Wert bietet und Fehler bei falscher Nutzung nicht abfängt. Die Lösung ist InjectionKey aus Vue: ein getyptes Symbol, das sowohl als Schlüssel für provide als auch für inject verwendet wird. Der generische Typparameter des Symbols bestimmt den Typ des bereitgestellten Werts – TypeScript kennt diesen Typ dann automatisch beim inject-Aufruf.
Die Best Practice: Alle InjectionKeys in einer zentralen Datei injection-keys.ts definieren, damit sie von allen Komponenten importiert werden können. Jeder Key ist ein Symbol – das stellt sicher, dass Keys global eindeutig sind, selbst wenn zwei verschiedene Bibliotheken denselben Schlüsselnamen verwenden würden. Durch den Vergleich nach Referenz (nicht nach Wert) können Symbol-Keys niemals versehentlich mit anderen Keys kollidieren, was bei String-Keys ein reales Problem ist, besonders wenn man Bibliotheks-Komponenten verwendet die ebenfalls provide/inject einsetzen.
// injection-keys.ts — Central definition of all InjectionKeys
import type { InjectionKey, Ref, DeepReadonly } from 'vue'
// Form state injected into multi-step form children
export interface FormState {
currentStep: DeepReadonly<Ref<number>>
totalSteps: number
nextStep: () => void
prevStep: () => void
updateData: (data: Record<string, unknown>) => void
}
export const FORM_STATE_KEY: InjectionKey<FormState> = Symbol('FormState')
// Theme context injected from layout into all page children
export interface ThemeContext {
colorScheme: DeepReadonly<Ref<'light' | 'dark'>>
toggleColorScheme: () => void
}
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('ThemeContext')
// components/FormStep.vue — typed injection
import { inject } from 'vue'
import { FORM_STATE_KEY } from '~/injection-keys'
const formState = inject(FORM_STATE_KEY)
// formState is now typed as FormState | undefined — TypeScript enforces null check
if (!formState) throw new Error('FormStep must be used inside MultiStepForm')
4. Reaktive Werte bereitstellen und schützen
Ein wichtiger Aspekt von Vue provide/inject: Die Reaktivität eines bereitgestellten Werts bleibt erhalten. Wenn ein ref oder ein reactive-Objekt über provide bereitgestellt wird, sehen alle Komponenten die inject aufrufen, Aktualisierungen automatisch – genauso wie bei Props. Das macht provide/inject besonders leistungsfähig für geteilten Zustand innerhalb einer Komponentenfamilie: Der Zustand muss nicht durch jede Ebene als Prop weitergereicht werden, und Änderungen an einer Stelle aktualisieren alle abhängigen Komponenten.
Das kritische Sicherheitsdetail: Wenn ein veränderbarer ref direkt bereitgestellt wird, können injizierende Komponenten ihn direkt mutieren – injectedRef.value = 'neuer Wert' – und damit den One-Way Data Flow verletzen, den provide/inject eigentlich unterstützen sollte. Die Lösung ist readonly(ref): Der bereitgestellte Wert ist dann nur lesbar, Mutations-Versuche lösen eine Vue-Warnung aus. Für Zustandsänderungen werden stattdessen explizite Funktionen bereitgestellt (updateData, nextStep), die die Eltern-Komponente kontrolliert und die einzige Möglichkeit sind, den Zustand zu ändern – das ist das Äquivalent von Emits für das provide/inject-Muster.
5. provide/inject in Composables kapseln
Das eleganteste Muster für Vue provide/inject ist die Kapselung in ein Composable-Paar: useFormStateProvider() für die providende Komponente und useFormState() für die konsumierenden Komponenten. Dadurch verschwindet die provide- und inject-Komplexität hinter einer klaren API. Die Konsumenten-Komponenten wissen nicht einmal, dass intern inject verwendet wird – für sie ist useFormState() einfach ein Composable das den Formular-Zustand zurückgibt. Das ermöglicht es, die Implementierung später zu ändern – zum Beispiel von provide/inject zu Pinia – ohne die konsumierenden Komponenten zu ändern.
Ein weiterer Vorteil des Composable-Musters: Der inject-Aufruf mit Fehlerbehandlung liegt an einer Stelle, nicht in jeder konsumierenden Komponente. Das Composable prüft, ob der injizierte Wert vorhanden ist, und wirft einen aussagekräftigen Fehler, wenn eine Komponente außerhalb des erwarteten Kontexts verwendet wird. In der Entwicklungsumgebung gibt das eine präzise Fehlermeldung wie „useFormState muss innerhalb von MultiStepForm verwendet werden", statt einem kryptischen TypeError: Cannot read properties of undefined irgendwo in der Komponentenlogik.
// composables/useFormState.ts — Encapsulated provide/inject pair
import { provide, inject, ref, readonly, computed } from 'vue'
import { FORM_STATE_KEY, type FormState } from '~/injection-keys'
// Called in the parent component (MultiStepForm)
export function useFormStateProvider(totalSteps: number) {
const currentStep = ref(1)
const formData = ref<Record<string, unknown>>({})
const state: FormState = {
currentStep: readonly(currentStep),
totalSteps,
nextStep: () => { if (currentStep.value < totalSteps) currentStep.value++ },
prevStep: () => { if (currentStep.value > 1) currentStep.value-- },
updateData: (data) => { formData.value = { ...formData.value, ...data } },
}
provide(FORM_STATE_KEY, state)
// Provider also returns state for use in the parent itself
return state
}
// Called in any descendant component (FormStep, FormNavigation, etc.)
export function useFormState(): FormState {
const state = inject(FORM_STATE_KEY)
if (!state) {
throw new Error('[useFormState] must be called inside a MultiStepForm component.')
}
return state
}
6. Plugin-Pattern: App-weites provide
Vue-Plugins nutzen provide/inject auf App-Ebene: app.provide(key, value) in der install-Methode eines Plugins macht einen Wert für die gesamte Applikation verfügbar, ohne einen globalen Store zu benötigen. Das ist das Muster, das viele populäre Vue-Bibliotheken verwenden – Vue Router stellt den Router über provide bereit, i18n-Bibliotheken stellen die Translation-Funktion bereit, und Theming-Bibliotheken stellen das aktuelle Theme bereit. Der Konsument ruft inject(routerKey) auf und bekommt den Router, ohne dass er importiert werden muss.
Für eigene Applikationsinfrastruktur ist das Plugin-Pattern mit provide/inject eine elegante Alternative zu importierten Singleton-Objekten. Ein API-Client zum Beispiel kann als Plugin installiert und über inject überall konsumiert werden: const apiClient = inject(API_CLIENT_KEY)!. Das ermöglicht es, den API-Client in Tests durch einen Mock-Client zu ersetzen – indem der Test-Wrapper einen anderen provide-Wert für denselben Key setzt. Mit importierten Singletons ist das Mocking deutlich aufwändiger und erfordert vitreous-Mocking-Setups.
7. Readonly-Wrapping und Mutationsschutz
Das readonly-Utility aus Vue erstellt einen Read-only-Proxy um ein reaktives Objekt oder einen Ref. Mutations-Versuche auf einem readonly-Wert lösen in der Entwicklungsumgebung eine Konsolenwarnung aus: „Set operation on key 'name' failed: target is readonly." Das ist ein wichtiges Sicherheitsnetz für provide/inject: Es verhindert, dass konsumierende Komponenten versehentlich den bereitgestellten Zustand direkt mutieren statt die bereitgestellten Mutationsfunktionen zu verwenden.
DeepReadonly aus TypeScript und readonly() aus Vue arbeiten zusammen: Das Laufzeit-readonly() schützt vor tatsächlichen Mutationen und gibt Warnungen aus, während DeepReadonly<T> als TypeScript-Typ dem Compiler mitteilt, dass der Typ nicht mutierbar ist. So fängt TypeScript bereits zur Compile-Zeit Versuche ab, auf ein readonly-Feld zu schreiben – bevor der Code überhaupt im Browser läuft. Die Kombination aus beiden gibt die stärkste Sicherheitsgarantie für bereitgestellte Zustände in Vue provide/inject-Muster.
8. Wann provide/inject, wann Pinia, wann Props?
Die Entscheidung zwischen Vue provide/inject, Pinia und direkten Props hängt vom Scope und der Lebenszeit des Zustands ab. Props sind richtig, wenn Daten direkt zwischen Eltern- und Kindkomponente fließen – eine oder maximal zwei Ebenen. Wenn der Zustand nur für eine bestimmte Komponentenfamilie relevant ist und an deren Lebenszyklus gebunden ist – ein Formular, ein Wizard, ein konfigurierbares Widget – ist provide/inject die richtige Wahl. Wenn der Zustand global relevant ist, über Seitenwechsel hinweg persistiert oder von nicht-verwandten Komponenten geteilt wird, ist Pinia die richtige Wahl.
Ein häufiger Fehler: provide/inject wird für globalen Zustand verwendet, indem auf der Root-Komponente oder im App-Plugin bereitgestellt wird. Das funktioniert technisch, hat aber den Nachteil, dass der Zustand keine DevTools-Integration hat wie Pinia, kein Time-Travel-Debugging ermöglicht und schwerer zu testen ist. Für wirklich globalen Zustand ist Pinia mit seinen DevTools-Plugins und seiner Test-Infrastruktur klar überlegen. Vue provide/inject glänzt im mittleren Bereich: mehr als direktes Prop-Drilling, weniger als vollständiger globaler Store.
9. Kommunikationsmuster im Vergleich
Die Wahl des Kommunikationsmusters bestimmt die Wartbarkeit und Testbarkeit von Vue-Applikationen. Die folgende Tabelle zeigt, welches Muster für welchen Anwendungsfall empfohlen wird.
| Muster | Scope | Ideal für | Nicht geeignet für |
|---|---|---|---|
| Props / Emits | Direkt: Eltern → Kind | 1–2 Ebenen, explizite Datenweitergabe | Tiefe Komponentenbäume (Prop Drilling) |
| provide/inject | Subtree: Provider bis beliebige Tiefe | Komponentenfamilien, Wizards, Widgets | Komponentenübergreifender globaler Zustand |
| Pinia Store | Global: gesamte Applikation | Auth, Theme, Warenkorb, globale Daten | Lokalem, kurzlebigem Zustand |
| Event Bus (mitt) | Global: beliebige Komponenten | Lose gekoppelte One-Time-Events | Reaktiver Zustand, häufige Updates |
| Composable (shared ref) | Modul-Singleton | Leichtgewichtiger Modul-Zustand | Scoped oder Server-Side-Rendering |
Vue provide/inject füllt die Lücke zwischen direkten Props und globalen Stores. Die Entscheidungsregel: Wenn du merkst, dass du Props durch Zwischenschichten reichst die sie nicht nutzen, ist provide/inject die richtige Wahl. Wenn du merkst, dass du provide/inject auf App-Ebene für Daten verwendest, die über Seitenwechsel hinweg relevant sind, solltest du zu Pinia wechseln. Jedes Muster hat seinen Platz – der Fehler liegt darin, immer dasselbe Muster zu verwenden unabhängig vom Anwendungsfall.
Mironsoft
Vue.js · Vue 3 · Komponentenarchitektur · State Management
Saubere Zustandsarchitektur für eure Vue-Applikation?
Wir analysieren eure Vue-Codebasis auf Prop-Drilling-Probleme und überdimensionierte globale Stores, und empfehlen die richtige Mischung aus Props, provide/inject und Pinia.
Architektur-Review
Prop-Drilling-Muster erkennen und durch provide/inject oder Pinia ersetzen
TypeScript-Migration
InjectionKeys typsicher implementieren und Composable-Wrapper aufbauen
State-Management-Strategie
Klare Entscheidungsregeln für Props, provide/inject und Pinia in eurem Projekt
10. Zusammenfassung
Vue provide/inject ist der richtige Mechanismus, wenn Props durch Zwischenschichten gereicht werden müssen, die sie nicht verwenden, und wenn ein globaler Pinia-Store für den Anwendungsfall überdimensioniert wäre. Das Muster funktioniert am besten, wenn es in Composable-Paare gekapselt wird (useXProvider + useX), typsichere InjectionKey<T>-Symbole verwendet und bereitgestellte Werte mit readonly() gegen direkte Mutationen schützt. Das Ergebnis ist scoped Dependency Injection, die den Lebenszyklus der Provider-Komponente teilt und automatisch aufgeräumt wird.
Die Entscheidungsregel: Props für direkte Eltern-Kind-Kommunikation (1–2 Ebenen), provide/inject für Komponentenfamilien mit gemeinsamem scoped Zustand, Pinia für globalen Zustand der über Seitenwechsel persistiert. Alle drei Mechanismen haben ihre Berechtigung – der Fehler liegt nicht im Werkzeug, sondern darin, immer dasselbe Werkzeug für alle Probleme zu verwenden. Eine Vue-Applikation die alle drei Muster an den richtigen Stellen einsetzt, ist wartbarer, testbarer und verständlicher als eine, die ausschließlich auf globale Pinia-Stores oder auf reines Prop-Drilling setzt.
Vue provide/inject — Das Wichtigste auf einen Blick
InjectionKey
Symbol<T> als typsicherer Key – InjectionKey<T> sorgt dafür, dass TypeScript den injizierten Typ kennt. Alle Keys zentral in injection-keys.ts.
Readonly-Schutz
readonly() um reaktive Werte bevor sie bereitgestellt werden. Mutationsfunktionen explizit bereitstellen – nie den Ref selbst beschreibbar machen.
Composable-Pattern
useXProvider() für Elternkomponente, useX() für Konsumenten. Fehlerbehandlung und InjectionKey-Logik liegt an einer Stelle.
Wann einsetzen
Prop Drilling über 2+ Ebenen ohne Nutzung in Zwischenschichten. Scoped Zustand für Komponentenfamilien. Nicht für globalen persistenten Zustand.