The Art of State: Zustandsmanagement in React-Anwendung, Teil 2
Der zweite Artikel zum State-Management in React stellt zwei Optionen für den Einsatz globalen Zustands vor: die React Context API und die Redux-Bibliothek.
![](https://heise.cloudimg.io/width/610/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/3/0/2/0/1/6/4/React-a7c2a75e099d294e.jpeg)
- Nils Hartmann
Zur Veranschaulichung der in diesem Artikel gezeigten Konzepte und Bibliotheken dient eine kleine Blog-Anwendung. Der Beispiel-Code, um die Funktionsweise auszuprobieren und nachzuvollziehen, befindet sich auf GitHub.
Das Verwalten und Verteilen von Zustand ist in großen Anwendungen eine große Herausforderung: Wie kommt er zuverlässig und schnell an die Stellen der Anwendung, die den jeweiligen Zustand benötigen? Der Standardweg in React ist dabei das Herunterreichen von Properties: Eine höher gelegene Komponente hält den Zustand. Über Properties an den Komponenten wird der Zustand ganz oder in Teilen in der Hierarchie explizit bis an die Stelle weitergereicht, an der er zum Einsatz kommt. Das betrifft nicht nur Daten, sondern auch Funktionen, die das Verändern des Zustands auslösen können.
In der Blog-Anwendung aus dem ersten Artikel werden die Filter-Einstellungen in einer Oberkomponente gehalten. Die Darstellung und damit einhergehende Interaktionsmöglichkeiten des Filters sind in einer Unterkomponente implementiert. Deshalb reicht die Oberkomponente nicht nur die aktuellen Filter-Einstellungen der Benutzer an die Unterkomponente, sie übergibt außerdem die Callback-Funktion onFilterByLikes
. Die Unterkomponente ruft diese Funktion auf, wenn Benutzer den Filter verändert haben.
Die Oberkomponente setzt daraufhin die neuen Filtereinstellungen in ihren Zustand (mit useState
oder useReducer
). Als Folge der Zustandsänderung rendert React daraufhin sowohl die Ober- als auch die Unterkomponente, Letztere erhält nun die aktualisierten Filtereinstellungen und kann diese darstellen.
Dieses explizite Durchreichen von Zustand stößt allerdings an einigen Stellen an seine Grenzen: etwa wenn Zustand über viele Ebenen weiterzureichen ist, die sich unter Umständen für die Information überhaupt nicht interessieren. Zum Beispiel könnte global ein Theme eingestellt sein. Allerdings ist diese Information tendenziell nur für Komponenten an der untersten Hierarchieebene relevant, da diese die Theme-Informationen für die korrekte Darstellung benötigen. Das können zum Beispiel Komponenten aus einer Komponentenbibliothek sein. Alle dazwischen liegenden Komponenten müssen das Theme-Property annehmen und weiterreichen, auch wenn sie (fachlich) damit gar nichts zu tun haben.
Dieser Ansatz setzt außerdem voraus, dass alle Komponenten innerhalb der Hierarchie ihre Unterkomponenten zur Entwicklungszeit kennen und das Theme-Property weitergeben können. Das muss aber nicht immer der Fall sein. Als Beispiel hierfür sollen Formulare dienen. Die PostEditor
-Komponente aus dem ersten Artikel enthält zwei Eingabefelder, die Benutzer ausfüllen können. In einer realistischen Anwendung würden diese beiden Felder beim Bearbeiten des Formulars validiert (zum Beispiel: Der Titel darf nur maximal 120 Zeichen lang sein und der Inhalt darf nicht leer sein). Zusätzlich ist festzuhalten, ob ein Feld überhaupt besucht wurde, damit die Validierungen erstmalig ausgeführt werden, nachdem die Benutzer überhaupt ein Feld angesteuert hatten. Dann ist für jedes Feld das Ergebnis der Validierung zu verwalten. Diese Anforderungen können beliebig komplex werden, sodass Entwickler eine Form-Komponente bauen könnten, die sich an zentraler Stelle um die Validierung kümmert.
Für die einzelnen Elemente der Form (input
, select
...) werden ebenfalls eigene Komponenten gebaut, die mit dem Form-Objekt kommunizieren sollen, in dem sie Ereignisse auslösen ("Eingabe hat sich geändert", "Feld wurde besucht") und Daten entgegennehmen (eingegebener Text des Feldes, Validierungsergebnis). Ein entsprechendes Formular könnte wie folgt aussehen:
function PostEditor(props) {
return (
<Form>
<TextField name="title" />
<TextField name="body" />
<Button onClick={. . .}>
Add Post
</Button>
</Form>
);
}
Die genaue Funktionsweise der Form
-Komponente ist für dieses Beispiel unerheblich. Interessant ist hingegen, dass die unter ihr liegenden Komponenten mit ihr kommunizieren müssen, und zwar ohne dass die Form-Komponente ihre einzelnen Kinder kennt. Das explizite Durchreichen von Properties ist an der Stelle deswegen nur schwer möglich, da die Form-Komponente die "fertigen" Kinder nur entgegennimmt.
Der React Context für globalen Zustand
In solchen Fällen lässt sich der React Context verwenden. Er stellt ein beliebiges Objekt über Komponentengrenzen hinweg zur Verfügung. Der Kontext besteht aus einer Provider-Komponente, die in der Komponentenhierarchie der Anwendung eingebunden wird. Alle darunter liegenden Komponenten können dann mit dem useContext
-Hook auf die Werte aus dem Kontext zugreifen. Sobald sich die Werte ändern, rendert React die Consumer-Komponenten neu, sodass diese die aktualisierten Daten darstellen können.
Ein Kontext wird mit der Funktion React.createContext
erzeugt. Diese Funktion liefert unter anderem eine Consumer-Komponente zurück. Die Provider-Komponente wird in einer eigenen fachlichen Komponente benutzt, um das Objekt zur Verfügung zu stellen, zum Beispiel so:
const FormContext = React.createContext();
function Form(props) {
const [formState, setFormState] = React.useState({...});
// ausgelassen: Die eigentliche Logik und Funktion der Form
// Der Context soll hier aus einem Objekt bestehen, dessen
// Keys die Namen der Form-Elemente sind.
// Die zugehörigen Values halten die Information für ein konkretes
// Feld (z.B. Inhalt, Fehlermeldung etc).
// Außerdem enthält der Context eine Funktion zum
// Ändern der Feld-Werte
return <FormContext.Provider value={ formState }>
{props.children}
</form>;
}
Alle Komponenten unterhalb der Form
-Komponente können nun mit useContext
Zugriff auf den Zustand der Form bekommen, ohne dass die Form-Komponente sie kennen muss. Der Kontext kann auch Callback-Funktionen enthalten, mit denen eine konsumierende Komponente die Provider-Komponente über Ereignisse informieren kann. Die Provider-Komponente ihrerseits kann dann ihren Zustand und den Kontext aktualisieren, woraufhin die konsumierenden Komponenten neu gerendert werden. Das Verfahren entspricht grundsätzlich dem oben beschriebenen Durchreichen von Zustand und Callback-Funktionen, nur dass mit dem Kontext Zustand und Funktionen nicht explizit über jede Schicht in der Komponentenhierarchie weiterzugeben sind.
Eine Komponente innerhalb der Form könnte beispielsweise wie folgt mit dem Kontext interagieren, um einerseits einen Wert daraus anzuzeigen und eine Callback-Funktion zur Veränderung aufzurufen:
function TextField({name}) {
// Context auswählen.
// Änderung des Context rendert das TextField neu
const formState = React.useContext(FormContext);
// Context verwenden
const fieldValue = formState[name]?.value || "";
const onChange = e => formState.onFieldChange(name, e.target.value);
return <input value={fieldValue} onChange={onChange} />
}
Beim Verwenden der Form
- beziehungsweise TextField
-Komponente ändert sich nichts, der Code für die weiter oben gezeigte PostEditor
-Komponente würde nun funktionieren. Der Kontext lässt sich auf jeder Ebene der Anwendung einsetzen, er wirkt immer nur auf die Komponenten, die sich in der Hierarchie unterhalb der Provider-Komponente befinden. Er lässt sich also für komplett globalen Zustand (Theme) als auch für "teilglobalen" Zustand wie Formulare oder einzelne Bereiche der Anwendung einsetzen. Aus Performancegründen ist zu empfehlen, den Context nur dort einzusetzen, wo der verwaltete Zustand nicht häufig und gleichzeitig schnell verändert wird, beziehungsweise dort, wo nicht viele Komponenten von der Änderung des Kontexts betroffen sind.