<v/>
{ }
Vue.js · Vue 3 · provide/inject · Dependency Injection · TypeScript
Vue provide/inject richtig nutzen
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.

15 Min. Lesezeit InjectionKey · provide · inject · readonly · Symbol · Plugin-Pattern Vue 3 · Composition API · TypeScript · Pinia

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.

11. FAQ: Vue provide/inject statt Prop Drilling oder globale Stores

1provide/inject vs. Pinia?
provide/inject ist scoped zum Subtree, Pinia ist global. Pinia hat DevTools und Time-Travel. Lokalem Komponentenzustand: provide/inject. Globaler Zustand: Pinia.
2Typsicherheit mit TypeScript?
InjectionKey<T> = Symbol('Key'). Denselben Key in provide() und inject() verwenden. TypeScript leitet Typ automatisch ab.
3Mutation durch Kindkomponenten verhindern?
readonly() vor dem provide(). Explizite Mutationsfunktionen bereitstellen – nie den Ref selbst beschreibbar machen.
4Was wenn inject() nichts findet?
Gibt undefined zurück oder angegebenen Defaultwert. TypeScript erzwingt Null-Prüfung – gute Absicherung für falsch verwendete Komponenten.
5App-weite Services mit provide?
app.provide() in einem Plugin-install. Wie Vue Router und VueI18n. In Tests einfacher zu mocken als importierte Singletons.
6Wann provide/inject statt Props?
Wenn Daten durch 2+ Ebenen gereicht werden die sie nicht nutzen – das ist Prop Drilling. provide/inject eliminiert die Zwischenschichten.
7Warum Symbols als Keys?
Referenziell einmalig – verhindert Kollisionen mit anderen Bibliotheken die denselben String-Schlüssel verwenden könnten.
8Komponenten mit inject testen?
global.provide in Vue Test Utils: mount(Component, { global: { provide: { [KEY]: mockValue } } }). Kein echtes Provider-Mount nötig.
9Bleibt Reaktivität bei provide/inject erhalten?
Ja. ref und reactive bleiben reaktiv. readonly() erhält Reaktivität und fügt Mutationsschutz hinzu. Alle Konsumenten sehen Änderungen automatisch.
10provide/inject in Composables?
Empfohlenes Muster: useXProvider() kapselt provide(), useX() kapselt inject() mit Fehlerbehandlung. Komponenten sehen nur die Composable-API.