React-Komponenten verbinden: Welcher Weg passt wann?

Props, Context, Store, Server State, URL oder Ereignis: eine Entscheidungshilfe nach Entfernung und Art des Werts
Aktualisiert:
Abstract
- #React-Komponenten verbinden
- #React-Kommunikation
- #Props, Context, Stores, Server State
- #React-Komponenten Datenaustausch
- #React
- #State Management
- #TanStack Query
Wie zwei React-Komponenten Daten austauschen, ohne dass die Anwendung unübersichtlich wird
Eine Filterleiste oben links, eine Ergebnistabelle in der Mitte, ein Zähler im Seitenkopf, der anzeigt, wie viele Treffer es gibt: Drei Komponenten, die dieselbe Information brauchen, aber an ganz unterschiedlichen Stellen der Anwendung sitzen. In React gibt es mindestens sechs Wege, sie zu verbinden. Die meisten davon funktionieren, aber nur einer oder zwei passen wirklich.
Welcher das ist, entscheiden fast immer zwei Fragen. Erstens: Wie weit liegen die Komponenten im Komponentenbaum auseinander, also in der Hierarchie, in der React die Oberfläche aus verschachtelten Bausteinen aufbaut? Zweitens: Was für ein Wert wandert zwischen ihnen, ein lokaler Schalter, eine app-weite Einstellung, eine Kopie von Serverdaten, ein Teil der aktuellen Ansicht oder ein einmaliges Ereignis? Die folgenden Abschnitte gehen die Möglichkeiten von nah nach fern durch.
Props und Callbacks für direkte Nachbarn
Der einfachste Fall: Eine Eltern-Komponente rendert ein Kind, und beide müssen sich auf einen Wert verständigen. Dafür braucht React kein Zusatzwerkzeug. Daten gehen als Props nach unten, Änderungswünsche als Callbacks, also übergebene Funktionen, wieder nach oben.
Teilen sich zwei Geschwister einen Wert, gehört der Zustand in die nächste gemeinsame Eltern-Komponente. Die React-Dokumentation nennt das „Lifting State Up“, das Hochziehen des Zustands. Angenommen, ein Webshop zeigt eine Produktliste mit Suchfeld:
import { useState } from 'react';
function Produktsuche({ produkte }) {
const [suchbegriff, setSuchbegriff] = useState('');
const treffer = produkte.filter((p) =>
p.name.toLowerCase().includes(suchbegriff.toLowerCase()),
);
return (
<>
<Suchfeld wert={suchbegriff} beiAenderung={setSuchbegriff} />
<Produktliste produkte={treffer} />
</>
);
}
function Suchfeld({ wert, beiAenderung }) {
return <input value={wert} onChange={(e) => beiAenderung(e.target.value)} />;
}
Die gefilterte Liste ist kein eigener Zustand, sie wird beim Rendern aus Suchbegriff und Produkten berechnet. Das erspart einen zweiten useState und einen Effekt, der beide synchron halten müsste.
Prop Drilling und wie Komposition es auflöst
Unangenehm wird es, wenn ein Wert durch mehrere Ebenen gereicht werden muss, die ihn selbst gar nicht nutzen. Ein user-Objekt geht von Layout an Sidebar und erst von dort an Avatar, obwohl Layout und Sidebar nichts damit anfangen. Dieses Durchreichen heißt Prop Drilling.
Bevor ein Team deswegen zu Context oder einem Store greift, lohnt ein Blick auf Komposition. Statt Daten durchzureichen, übergibt die obere Komponente gleich das fertige Element als children oder als benanntes Prop:
function Seite({ user }) {
return (
<Layout sidebar={<Sidebar avatar={<Avatar user={user} />} />}>
<Inhalt />
</Layout>
);
}
Layout und Sidebar kennen den Benutzer jetzt nicht mehr, sie platzieren nur, was sie bekommen. Zwei oder drei Ebenen Prop Drilling sind außerdem kein Problem, sondern gut nachvollziehbar: Wer den Code liest, sieht genau, woher ein Wert kommt.
Muss der Zustand überhaupt geteilt werden?
Vor jeder Verbindung steht eine Gegenfrage: Brauchen die Komponenten den Wert wirklich gemeinsam? Häufig wandert Zustand „vorsorglich“ nach oben, weil ihn irgendwann vielleicht noch jemand brauchen könnte.
Angenommen, ein Dashboard hält sowohl den Suchbegriff als auch einen Schalter menueOffen in seiner obersten Komponente. Den Suchbegriff lesen Filter und Tabelle, den Schalter nur das Menü. Liegt der Schalter trotzdem oben, rendert bei jedem Öffnen des Menüs das ganze Dashboard neu. Gehört er ins Menü, betrifft die Änderung nur das Menü.
Dieses Prinzip heißt Colocation: Zustand so nah wie möglich an der Stelle halten, an der er gebraucht wird, und nur so hoch ziehen, wie es zwingend nötig ist. Der Code wird verständlicher, und die Anwendung rendert weniger. Viele vermeintlich gemeinsame Zustände entpuppen sich bei genauerem Hinsehen als lokale Werte, die nur an der falschen Stelle liegen. Mehr zur Frage, welche Art von Zustand wohin gehört, steht in unserem Beitrag React State Management: welcher Zustand wohin gehört.
Refs für Befehle an ein Kind
Nicht jede Verbindung dreht sich um gemeinsamen Zustand. Manchmal will eine Eltern-Komponente einem Kind einen Befehl geben: ein Eingabefeld fokussieren, ein Video starten, zu einer Zeile scrollen.
Dafür gibt es Refs, also Verweise auf ein DOM-Element oder ein Objekt, die zwischen den Renderdurchläufen erhalten bleiben. Mit useImperativeHandle legt das Kind fest, welche Methoden es nach außen anbietet. Seit React 19 ist ref bei Funktionskomponenten ein gewöhnliches Prop:
import { useImperativeHandle, useRef } from 'react';
function Videoplayer({ src, ref }) {
const videoRef = useRef(null);
// Nur diese beiden Methoden sind für die Eltern sichtbar
useImperativeHandle(ref, () => ({
abspielen: () => videoRef.current.play(),
anhalten: () => videoRef.current.pause(),
}));
return <video ref={videoRef} src={src} />;
}
function Schulung() {
const player = useRef(null);
return (
<>
<Videoplayer ref={player} src="/einfuehrung.mp4" />
<button onClick={() => player.current.abspielen()}>Start</button>
</>
);
}
In React 18 und älter muss das Kind dafür in forwardRef gehüllt werden. Die Unterscheidung, wann dieses Werkzeug passt, ist einfach: Lässt sich die Beziehung als Befehl formulieren („spiel ab“), ist eine Ref richtig. Lässt sie sich als gemeinsames Wissen formulieren („beide müssen wissen, ob das Video läuft“), ist es Zustand und gehört in Props oder einen der folgenden Wege.
Context für app-weite, selten wechselnde Werte
Jetzt wächst die Entfernung. Ein Wert wird an vielen Stellen in unterschiedlicher Tiefe gebraucht, Komposition würde den Baum unübersichtlich machen, und der Wert ändert sich kaum. Typische Kandidaten sind das Farbschema, die Sprache, der angemeldete Benutzer oder Einstellungen eines Designsystems.
Dafür ist Context gedacht. Eine Komponente stellt den Wert bereit, jede Komponente darunter kann ihn lesen, ohne dass die Ebenen dazwischen davon wissen. Seit React 19 lässt sich der Context direkt als Provider rendern, das frühere Context.Provider funktioniert weiterhin. Lesen geht mit useContext oder mit use, das auch innerhalb von Bedingungen erlaubt ist.
import { createContext, use, useState } from 'react';
const SpracheContext = createContext('de');
function App() {
const [sprache, setSprache] = useState('de');
return (
<SpracheContext value={sprache}>
<Kopfzeile onSprachwechsel={setSprache} />
<Hauptbereich />
</SpracheContext>
);
}
function Begruessung() {
const sprache = use(SpracheContext);
return <p>{sprache === 'de' ? 'Willkommen' : 'Welcome'}</p>;
}
Warum Context bei häufigen Änderungen bremst
Context hat eine Eigenschaft, die oft übersehen wird: Ändert sich der Wert, rendern alle Komponenten neu, die diesen Context lesen. Ein Context kennt keine Selektoren, eine Komponente kann also nicht sagen „mich interessiert nur das Feld name“. Auch React.memo hilft hier nicht, denn memo vergleicht nur Props, und Context-Konsumenten erhalten ihre Daten an den Props vorbei.
Besonders teuer wird das, wenn der Wert ein Objekt ist, das bei jedem Rendern neu gebaut wird, etwa value={{ user, logout }}. Für React ist das jedes Mal ein anderes Objekt, auch wenn der Inhalt gleich bleibt. Abhilfe schaffen schmale Contexts für einzelne Werte, die Trennung von Zustand und Änderungsfunktionen in zwei Contexts und ein stabiler Wert über useMemo. Mit dem React Compiler, der die Memoisierung automatisch einfügt, entfällt zumindest das letzte von Hand.
Wer dagegen beginnt, mehrere Contexts und Memoisierung zu stapeln, um Selektoren nachzubauen, setzt das Werkzeug für etwas ein, wofür es nicht gebaut ist. Ein Wert, der sich mehrmals pro Sekunde ändert und an vielen Stellen jeweils nur zum Teil gebraucht wird, gehört in einen Store.
Ein Store für echten globalen Client-Zustand
Ein Store ist ein Zustandsbehälter außerhalb des Komponentenbaums, den Komponenten gezielt abonnieren. Der entscheidende Unterschied zu Context: Jede Komponente liest über einen Selektor nur den Ausschnitt, der sie interessiert, und rendert nur neu, wenn sich genau dieser Ausschnitt ändert.
Eine schlanke und verbreitete Bibliothek dafür ist Zustand. Angenommen, ein Warenkorb soll im Seitenkopf die Anzahl der Artikel zeigen und auf der Produktseite Artikel aufnehmen:
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
const useWarenkorb = create((set) => ({
positionen: [],
hinzufuegen: (artikel) =>
set((s) => ({ positionen: [...s.positionen, artikel] })),
leeren: () => set({ positionen: [] }),
}));
function Warenkorbsymbol() {
// Rendert nur neu, wenn sich die Anzahl ändert
const anzahl = useWarenkorb((s) => s.positionen.length);
return <span>{anzahl}</span>;
}
function Aktionen({ artikel }) {
// Selektor liefert ein Objekt, daher flacher Vergleich mit useShallow
const { hinzufuegen, leeren } = useWarenkorb(
useShallow((s) => ({ hinzufuegen: s.hinzufuegen, leeren: s.leeren })),
);
return (
<>
<button onClick={() => hinzufuegen(artikel)}>In den Warenkorb</button>
<button onClick={leeren}>Warenkorb leeren</button>
</>
);
}
Der zweite Selektor zeigt die typische Falle: Gibt ein Selektor bei jedem Aufruf ein neues Objekt oder Array zurück, gilt das als Änderung, und die Komponente rendert ständig neu, seit Zustand 5 droht sogar eine Endlosschleife. useShallow vergleicht stattdessen die Inhalte. Weil der Store außerhalb von React lebt, lässt er sich zudem mit useWarenkorb.getState() aus gewöhnlichen Funktionen lesen, etwa aus einem Hilfsmodul für das Tracking.
Redux Toolkit ist die strukturiertere Alternative. Es gibt mehr Konventionen vor, bringt mit den Redux DevTools eine gute Nachverfolgung jeder Zustandsänderung mit und eignet sich für große, langlebige Anwendungen, an denen viele Entwickler:innen arbeiten. Jotai zerlegt Zustand in kleine, einzeln abonnierbare Einheiten, sogenannte Atome, was gut zu Formularen und Editoren mit vielen unabhängigen Feldern passt.
Für alle drei gilt: Ein Store ist für Zustand da, der tatsächlich global ist. Der häufigste Fehler ist nicht die Wahl der falschen Bibliothek, sondern lokaler Zustand im Store, nur weil man dort bequem von überall herankommt.
Server State gehört in einen Cache, nicht in den Store
Ein großer Teil dessen, was Teams als geteilten Zustand verwalten, stammt gar nicht aus der Anwendung. Benutzerprofil, Bestellliste, Lagerbestand: Das Original liegt in einer Datenbank auf dem Server, die Anwendung hält nur eine Kopie.
Diese Kopie verhält sich anders als Client-Zustand. Sie kann veralten, andere Nutzer können sie gleichzeitig ändern, und zwischen Anfrage und Antwort vergeht Zeit. Wer Serverdaten in einen Store legt, baut deshalb nach und nach Ladeflags, Fehlerbehandlung, Wiederholungen und Cache-Invalidierung selbst. Dafür gibt es spezialisierte Bibliotheken, am verbreitetsten TanStack Query:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
function useBestellungen() {
return useQuery({
queryKey: ['bestellungen'],
queryFn: () => fetch('/api/bestellungen').then((r) => r.json()),
});
}
function Bestellzaehler() {
const { data } = useBestellungen();
return <span>{data?.length ?? '–'}</span>;
}
function Stornieren({ id }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => fetch(`/api/bestellungen/${id}`, { method: 'DELETE' }),
// Danach gelten alle Daten unter diesem Schlüssel als veraltet
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['bestellungen'] }),
});
return <button onClick={() => mutation.mutate()}>Stornieren</button>;
}
Der QueryClient wird einmal an der Wurzel der Anwendung bereitgestellt. Lesen zehn Komponenten an verschiedenen Stellen dieselbe Query mit demselben Schlüssel, teilen sie sich einen Eintrag im Cache, und alle zeigen dieselben Daten. Nach der Stornierung lädt die Bibliothek die Liste neu, und der Zähler im Seitenkopf aktualisiert sich, ohne dass die beiden Komponenten voneinander wissen. Wo Serverdaten so verwaltet werden, schrumpft der globale Store oft auf wenige Werte zusammen.
Server Components verschieben die Frage
In Frameworks mit React Server Components, etwa dem App Router von Next.js, lädt eine Server Component Daten direkt auf dem Server und gibt sie als Props an Client Components weiter. Für viele Seiten entfällt damit der clientseitige Abruf ganz. Context und Stores stehen allerdings nur in Client Components zur Verfügung, weil Server Components keinen Zustand halten. Die Faustregel bleibt dieselbe: Daten vom Server kommen vom Server, entweder über eine Server Component oder über einen Cache wie TanStack Query, aber nicht über einen selbst gepflegten Store. Wie sich Hooks, Compiler und Server Components insgesamt verschieben, ordnet unser Beitrag Von React Hooks zu Server Components: Wohin sich React entwickelt ein.
Die URL als gemeinsamer Speicher für die Ansicht
Viel von dem, was als Anwendungszustand gilt, beschreibt eigentlich nur, was gerade auf dem Bildschirm zu sehen ist: der aktive Filter, die Seite einer Tabelle, die Sortierung, der gewählte Reiter. Für diese Werte bringt der Browser bereits einen globalen, dauerhaften und teilbaren Speicher mit, die URL mit ihren Suchparametern.
Liegt ein solcher Wert in der URL, bekommt die Anwendung drei Eigenschaften geschenkt. Ein Link gibt genau diese Ansicht wieder, etwa wenn eine Kollegin eine gefilterte Auftragsliste per Chat weiterleitet. Ein Neuladen der Seite verliert nichts. Und die Zurück-Taste des Browsers verhält sich so, wie Nutzer es erwarten.
Die Bibliothek nuqs macht einen Suchparameter so handlich wie useState, inklusive Typumwandlung:
import { useQueryState, parseAsInteger } from 'nuqs';
function Filterleiste() {
const [status, setStatus] = useQueryState('status');
return (
<select value={status ?? ''} onChange={(e) => setStatus(e.target.value)}>
<option value="">Alle</option>
<option value="offen">Offen</option>
</select>
);
}
function Tabellennavigation() {
const [seite, setSeite] = useQueryState(
'seite',
parseAsInteger.withDefault(1),
);
return <button onClick={() => setSeite(seite + 1)}>Nächste Seite</button>;
}
Die Anwendung muss dafür einmal in einen zum Router passenden Adapter von nuqs gehüllt werden, etwa für Next.js oder React Router. Filterleiste und Tabelle bleiben dann synchron, ohne gemeinsame Eltern, Context oder Store. Die Abgrenzung: Würde ein Nutzer die Ansicht verlinken oder als Lesezeichen speichern wollen, gehört der Wert in die URL. Ein aufgeklapptes Dropdown oder ein halb ausgefülltes Formularfeld bleibt lokal.
Ereignisse für Momente statt Werte
Bleibt der Fall zweier Komponenten ohne sinnvollen gemeinsamen Vorfahren, ohne gemeinsame Serverdaten und ohne gemeinsamen Teil der Ansicht. Ihre Beziehung lautet nicht „wir kennen beide denselben Wert“, sondern „wenn hier etwas passiert, soll dort etwas geschehen“.
Das klassische Beispiel sind Benachrichtigungen, sogenannte Toasts. Ausgelöst werden sie an vielen Stellen, angezeigt in einem einzigen Bereich des Layouts. Ein Toast ist kein dauerhafter Wert, sondern ein Ereignis, das kurz sichtbar ist und dann verschwindet.
Für solche Fälle passt Publish-Subscribe: Ein Sender meldet ein Ereignis, ohne zu wissen, wer zuhört, und Empfänger reagieren, ohne zu wissen, wer gemeldet hat. Der Browser bringt dafür die Klasse EventTarget mit:
// benachrichtigungen.js
export const benachrichtigungen = new EventTarget();
export function melde(text) {
benachrichtigungen.dispatchEvent(new CustomEvent('toast', { detail: text }));
}
import { useEffect, useState } from 'react';
import { benachrichtigungen } from './benachrichtigungen';
function ToastBereich() {
const [meldungen, setMeldungen] = useState([]);
useEffect(() => {
const beiToast = (e) => setMeldungen((m) => [...m, e.detail]);
benachrichtigungen.addEventListener('toast', beiToast);
return () => benachrichtigungen.removeEventListener('toast', beiToast);
}, []);
return meldungen.map((text, i) => <p key={i}>{text}</p>);
}
Die Entkopplung hat ihren Preis. Beim Debuggen lässt sich der Datenfluss nicht mehr im Komponentenbaum verfolgen, und ein Ereignis, das feuert, bevor der Empfänger lauscht, geht verloren. Deshalb gilt dieses Muster als letzter Ausweg für querliegende Signale, nicht als allgemeiner Kommunikationsweg. Viele Toast-Bibliotheken nutzen intern ein ähnliches Prinzip und nehmen einem die Details ab.
Entscheidungshilfe nach Entfernung und Art des Werts
Zusammengefasst ordnet sich jeder Fall über Entfernung und Art des Werts ein:
| Situation | Passender Weg |
|---|---|
| Eltern und Kind oder Geschwister | Props, Callbacks, Zustand hochziehen |
| Wert durch uninteressierte Ebenen | Komposition mit children |
| Befehl an ein Kind | Ref mit useImperativeHandle |
| App-weit, selten geändert | Context |
| Global, häufig geändert, teilweise gelesen | Store mit Selektoren |
| Kopie von Serverdaten | Server Component oder TanStack Query |
| Beschreibt die aktuelle Ansicht | URL-Parameter |
| Einmaliges Ereignis | Publish-Subscribe |
Die meisten Anwendungen kommen mit wenigen dieser Wege aus: Props und Callbacks fast überall, Context für eine Handvoll app-weiter Werte, eine Datenbibliothek oder Server Components für alles vom Backend und die URL für Filter und Seitenzahlen. Ein Store wird oft erst nötig, wenn echter Client-Zustand wie ein umfangreicher Editor oder ein Warenkorb ohne Serverabgleich hinzukommt.
Was das für Ihr Team bedeutet
Probleme entstehen selten, weil ein Team zu einfache Werkzeuge nutzt. Unübersichtlich wird eine React-Anwendung, wenn große Werkzeuge kleine Aufgaben übernehmen: ein globaler Store für einen Menüschalter, ein Context als Ablage für Serverdaten, ein Ereignisbus für einen Wert, der eigentlich als Prop durchgereicht werden könnte. Werkzeuge, die von überall erreichbar sind, verleiten dazu, nicht mehr zu fragen, wo ein Wert eigentlich hingehört.
Unsere Empfehlung: Beginnen Sie mit dem nächstliegenden Weg, der das Problem lösen kann, und gehen Sie erst eine Stufe weiter, wenn dieser Weg erkennbar an Grenzen stößt. Diese Haltung lässt sich gut in Code-Reviews verankern, etwa mit der Frage „Warum liegt dieser Zustand hier und nicht eine Ebene tiefer?“. Wie sich Komponenten von vornherein so zuschneiden lassen, dass wenig geteilt werden muss, beschreibt der Beitrag React-Komponenten strukturieren: Zuschnitt, Komposition und Hooks.
Wenn Sie eine bestehende Anwendung entflechten oder Ihr Team in Zustandsverwaltung, TanStack Query und Server Components schulen möchten, finden Sie unser Angebot auf der Seite React: Beratung, Coaching und Seminare.
Häufige Fragen zum Datenaustausch zwischen React-Komponenten
Wann reicht Context, und wann lohnt sich ein Store wie Zustand?
Context passt für Werte, die selten wechseln und an vielen Stellen gelesen werden, etwa Farbschema, Sprache oder den angemeldeten Benutzer. Ändert sich ein Wert häufig und brauchen viele Komponenten jeweils nur einen Teil davon, ist ein Store mit Selektoren besser, weil dann nur die betroffenen Komponenten neu rendern.
Warum sollten Serverdaten nicht im normalen Zustand liegen?
Weil sie nur eine Kopie sind, die veralten und von anderen geändert werden kann. Bibliotheken wie TanStack Query übernehmen Caching, Nachladen, Wiederholungen und das Verwerfen veralteter Daten. Selbst gebaut ist diese Logik aufwendig und fehleranfällig.
Ist Prop Drilling immer schlecht?
Nein. Werte über zwei oder drei Ebenen durchzureichen, ist übersichtlich und leicht nachzuvollziehen. Erst wenn viele Ebenen einen Wert nur weiterreichen, lohnt es sich gegenzusteuern, zuerst mit Komposition und erst danach mit Context oder einem Store.
Funktionieren Context und Stores mit Server Components?
Nur in Client Components, also in Dateien mit 'use client'. Server Components halten keinen Zustand und können keine Hooks nutzen. Ein Provider lässt sich aber als Client Component um Server-gerenderte Inhalte legen, sodass die Client Components darunter den Wert lesen können.
Kategorien
Schlagworte