Beliebte Suchanfragen
//

State Management in React

17.12.2021 | 12 Minuten Lesezeit

Dieser Artikel befasst sich mit dem State Management in React. Er besteht aus 3 Kapiteln und beantwortet die folgenden Fragen: Was ist State Management? Welche Probleme löst State Management? Welche Lösungen und Best-Practices gibt es? Der Artikel gibt nicht nur einen Überblick über das State Management in React, sondern bietet Entscheidungsgrundlagen, ob State Management überhaupt notwendig ist.

State Management ist ein komplexes Thema. Alle Frontend-Entwickler*innen wissen es. Sie fragen sich, was das beste Vorgehen ist, wenn sie den State zwischen verschiedenen Komponenten teilen und managen müssen.

Es gibt nicht nur sehr viele externe State-Management-Libraries (wie die populärsten, Redux und Mobx), auch React an sich enthält schon eine State-Management-Library. Das macht die Entscheidung noch schwieriger.

Folgende Fragen solltest Du Dir stellen, bevor Du Dich für eine Lösung entscheidest:

  1. Brauche ich überhaupt ein State-Management?
  2. Welche Lösung passt am besten zu meinem Use-Case?
  3. Wie kann ich die umgesetzte Lösung erweitern und pflegen, sobald meine Anwendung größer und komplexer wird?
  4. Wie kann ich das State-Management testen?*

Dieser Artikel gibt Antworten auf die Fragen 1–3. Frage 4 „Wie kann ich State Management testen?“ beantworte ich im nächsten Artikel.

1. Brauche ich überhaupt ein State-Management?

Es gibt leider keine einfache Antwort. Es kommt darauf an, wie groß und komplex die Anwendung ist und wie erfahren Du im Umgang mit React bist.

„Ja, super Ewa, jetzt bin ich noch mehr verwirrt und weiß nicht, ob ich überhaupt ein State-Management brauche.”

Wenn die Anwendung nicht sehr groß und komplex sondern klein und übersichtlich ist, musst Du sie nicht unnötig komplizierter machen. Für viele Use-Cases brauchst Du daher keine extra State-Management-Library.

Auch wenn Du Beginner bist und erst React lernst, solltest Du erstmal keine komplexe Library verwenden.

Wichtig ist in jedem Fall, dass Du schon am Anfang verstehst, wie Du in React denken musst:

  • Deine Komponenten sollten jeweils nur für eine Aufgabe verantwortlich sein. Je größer sie sind, desto komplexer und schwieriger werden sie zu testen. Du kannst Dir die Komponenten wie LEGO-Steine vorstellen.
  • Du solltest wissen, was der Unterschied zwischen State und Props ist.

Die Unterschiede und Gemeinsamkeiten von State und Props erkläre ich Dir hier noch einmal kurz.

State und Props

Was haben State und Props gemeinsam? Sie sind deterministisch und triggern ein Render-Update. Deterministisch bedeutet, wenn Deine Komponente verschiedene Outputs für die gleiche State- und Props-Kombination generiert, machst Du etwas falsch.

Was ist ein State? Nehmen wir zum Beispiel eine Checkbox. Eine Checkbox hat zwei Zustände — true und false. Wenn der User die Checkbox abhakt, ändert sich ihr Zustand. Diesen Zustand nennt man State. 

Darauf musst Du bei State achten:

  • Ganz oft ändern die User-Events den State
  • Der State ist private, nur die Komponente, die einen State hat, kann ihn ändern oder weitergeben
  • Der State hat einen initialen Default-Wert

Was sind Props? Props kannst Du Dir wie eine (Art) Konfiguration vorstellen. Die Props stellen die Parent-Komponenten zur Verfügung. Nehmen wir unsere Checkbox: Sie kann konfigurierbar sein, zum Beispiel einen Namen oder eine Farbe haben. Das sind die Props. Die Props sind immutable. Das bedeutet, dass die Komponente sie nicht ändern kann.

Eine sehr gute und noch ausführlichere Zusammenfassung kannst Du bei React-Guide finden.

Wir betrachten folgendes Beispiel, bei dem Du den Unterschied zwischen State und Props sehen kannst:

1interface CheckboxProps {
2  defaultChecked?: boolean;
3  color?: "primary" | "secondary" | "success";
4  name?: string;
5}
6
7const Checkbox = ({
8  defaultChecked = false,
9  color = "primary",
10  name,
11}: CheckboxProps) => {
12  const [checked, setChecked] = useState(defaultChecked);
13  const onChange = (event: React.ChangeEvent<HTMLInputElement>) =>
14    setChecked(event.target.checked);
15
16  return (
17    <>
18      <CheckboxMaterial checked={checked} onChange={onChange} color={color}/>
19      <span>{name}</span>
20    </>
21  );
22};
23
24export default Checkbox;

So kannst Du verschiedene Varianten der Checkbox mit den Props definieren:

1<Checkbox defaultChecked={true} name="My super Checkbox" />
2<Checkbox name="I'm having default configuration" />
3<Checkbox defaultChecked={true} color="success" />

So sieht es gerendert aus:

Du fragst Dich wahrscheinlich, wieso ich die Props und State erklärt und erwähnt habe. Es gibt Use-Cases, bei denen Du Props und State anstatt State-Management verwenden kannst. Es ergibt Sinn, wenn es eine Parent-Child-Relation gibt und dadurch die beiden Komponenten sehr abhängig von einander sind. So kann man alles schön einkapseln.

Du solltest die Props nicht verwenden, wenn nicht die direkte Child-Komponente die Daten benötigt, sondern erst das Grandchild. Ansonsten müssen die Komponenten die Props nur weiterleiten, obwohl sie die gar nicht brauchen und von denen eigentlich nichts wissen sollten. Das könnte zu Prop-Drilling führen. Was Prop-Drilling bedeutet, erkläre ich im nächsten Kapitel.

Es ist auch nicht nötig den internen Komponenten-State global zu speichern. Wenn die Parent-Komponente die Information benötigt, dass der Zustand der Child-Komponente sich geändert hat, kannst Du als Prop eine Callback-Funktion (zum Beispiel TabIndexChange) an die Parent-Komponente weitergeben.

2. Welche Lösung passt am besten zu meinem Use-Case?

In diesem Kapitel erläutere ich zwei State-Management-Lösungen: Redux und useContext & useReducer. Ich beschreibe, welche Probleme jede Lösung löst und auch wie Du mit asynchronen Aktionen jeweils umgehen kannst. Ich erkläre auch, was Prop-Drilling bedeutet. Wir fangen mit Redux an.

Redux

Ich gehe davon aus, dass Du das Redux-Konzept kennst. Wenn nicht, solltest Du erstmal mehr über die Grundprinzipien und Konzepte lesen.

Redux hilft, das Prop-Drilling zu reduzieren.

Prop-Drilling entsteht, wenn mehrere, aber nicht alle Komponenten (im Baum) die gleichen Informationen benötigen, wie zum Beispiel UI-Theme, Username oder Response-Status (Loading / Error / Success). Das Problem ist, dass die großen Props-Objekte an andere Teile des Komponenten-Baumes weitergegeben werden. Das führt dazu, dass viele Komponente die Props nur weiterleiten und die Daten gar nicht brauchen. Das macht:

  • den Code schwieriger zu verstehen — spätestens sobald die Anwendung größer ist und
  • die Erweiterung, das Refactoring und das Testing der Anwendung schwieriger. Stell Dir vor, Du hast ein Objekt, dass Du an viele Komponenten weitergibst: { username: string; firstName: string; lastName: string; }. Jetzt kommt ein neues Feld dazu: age. Du musst in diesem Fall alle betroffenen Komponenten anpassen und noch die Tests dazu.

Die React-Dokumentation empfiehlt, den geteilten State auf die nächste gemeinsame Parent-Komponente anzuheben:

Often, several components need to reflect the same changing data. We recommend lifting the shared state up to their closest common ancestor [1].

„Na gut Ewa, ich weiß schon was Prop-Drilling ist und es war auch der Grund, wieso wir uns im Projekt für Redux entschieden haben. Redux erfordert aber mega viel Boilerplate-Code. Was ist dann der Vorteil?”

Das stimmt. Deswegen beschreibe ich im Folgenden das React-Konzept von useContext & useReducer als State-Management-Lösung. Aber bevor wir diesem Teil begegnen, noch ein paar Worte über Redux Toolkit. Vielleicht wäre das eine gute Redux-Alternative für Dich.

Dem Redux-Team waren alle diese Probleme wie Boilerplate-Code und komplizierte Nutzung von Redux bewusst. Deswegen haben sie versucht, das Redux-Konzept zu vereinfachen. Das neue Konzept heißt Redux Toolkit und laut der Redux-Dokumentation ist das der neue und empfohlene Ansatz, die Redux-Logik zu schreiben.

Redux Toolkit:

  • ist sehr flexibel
  • hilft, die häufigsten Fehler zu vermeiden
  • enthält die Best-Practices
  • beschleunigt das Development und vereinfacht die Nutzung von Redux
  • ist gut testbar

Wir betrachten folgendes Beispiel, bei dem Du sehen kannst, wie Du einen Ausschnitt aus dem Redux-State, mithilfe von Redux Toolkit definieren kannst:

1export interface UserState {
2  userDetails: User | undefined;
3  error: string | null;
4  status: "idle" | "in_progress" | "success" | "error";
5}
6
7export const initialUserState: UserState = {
8  userDetails: undefined,
9  error: null,
10  status: "idle",
11};
12
13export const fetchUser = 
14      createAction<{ username: string }>("user/fetchUser");
15
16export const userSlice = createSlice({
17  name: "user",
18  initialState: initialUserState,
19  reducers: {
20    setUserDetails: (state, action: PayloadAction<User>) => {
21      state.userDetails = action.payload;
22      state.status = "success";
23    },
24  },
25});
26
27const selectors = {
28  selectUserDetails: (state: RootState) => state.user.userDetails,
29};
30
31export const { selectUserDetails } = selectors;
32export const { setUserDetails } = userSlice.actions;

Wenn Du Redux kennst, merkst Du schon, dass der Code viel einfacher und verständlicher ist. Anstatt zwei Files für Actions und Reducers gibt es jetzt nur einen Ausschnitt aus dem Redux-State, den man Slice nennt. Die Implementierung von Reducers enthält weniger Komplexität, weil sich unter der Haube die Immer-Library um die Immutable-State-Updates kümmert.

Über die Immer-Library kannst Du in der Redux-Toolkit-Dokumentation mehr lesen.

Das Abschicken von Actions und Verwenden von Selektoren ist auch sehr einfach:

1const UserProfile = () => {
2  const user = useSelector(selectUserDetails);
3  const dispatch = useDispatch();
4
5  useEffect(() => {
6    dispatch(fetchUser({ username: "ewa" }));
7  }, [dispatch]);
8
9  return <>
10      <div>Username: {user?.username}</div>
11      <div>First name: {user?.firstName}</div>
12      <div>Last name: {user?.lastName}</div>
13  </>;
14};
15
16export default UserProfile;

Du hast Dich bestimmt gefragt, was passiert, wenn eine asynchrone Aktion, wie fetchUser im oberen Beispiel, abgeschickt wird. Wie kann man einen Request abschicken, auf die Response warten und letztendlich den Store entsprechend aktualisieren? Im idealen Fall auch noch auf die Errors reagieren, etwas loggen, dem User einen Loading-Spinner zeigen und viel mehr. Du willst bestimmt nicht das Gleiche (Loading-Spinner, Success-Notification, Error-Handling, …) in jeder Komponente wiederholen.

Wie kannst Du dieses Problem lösen? Die Antwort lautet: „Middleware to the rescue!”

Mit Middleware kann man jede abgeschickte Aktion abfangen, die Änderungen vornehmen und sogar die Aktion abbrechen. Middleware hilft bei:

  • dem Logging
  • dem Error-Handling
  • asynchronen Abfragen
  • und vielem mehr…

Ich persönlich mag redux-saga am liebsten. Hier ein Beispiel, wie man asynchrone Requests abschicken und den State aktualisieren kann. In Saga kannst Du viel mehr machen, zum Beispiel den User auf eine andere Seite weiterleiten.

1export function* fetchUserSaga(
2                    action: PayloadAction<{username: string}>) {
3    try {
4        const username = action.payload.username
5        const user: User = yield call(userApi.fetchUser, username);
6        yield put(setUserDetails(user));
7        yield put(push(routes.welcomePage.path)); // redirect
8    } catch (error) {
9        yield put(errorOccurred({ error }));
10    }
11}
12
13export function* watcherUserSagas() {
14    yield takeLatest(fetchUser.type, fetchUserSaga);
15}

useContext & useReducer

Aber genug zu Redux! React, wie oben erwähnt, bietet auch eigene Möglichkeiten das State-Management zu implementieren – ganz ohne externe Bibliotheken wie Redux (Toolkit). Diese Lösung heißt Context.

Context teilt Daten zwischen einer Gruppe von Komponenten, die die gleichen Daten benötigen. Das bedeutet, dass es nicht nötig ist, manuell Props auf jede Baum-Ebene zu übergeben. Die Daten, die man gruppieren kann, sind zum Beispiel: UI-Theme oder eingeloggter User.

useContext & useReducer Pattern:

  • ist keine zusätzliche Library, alles basiert auf React
  • ist sehr flexibel, man muss aber die Patterns teilweise selbst umsetzen (siehe Beispiel unten)

Betrachten wir dieses Beispiel:

1type UserState = {
2  userDetails: User | undefined;
3};
4
5type UserAction =
6  | { type: "fetchUser"; payload: { username: string } }
7  | { type: "setUserDetails"; payload: User | undefined };
8
9type Dispatch = (action: UserAction) => void;
10
11const UserContext = createContext<
12  { state: UserState; dispatch: Dispatch } | undefined
13>(undefined);
14
15function userReducer(state: UserState, action: UserAction) {
16  switch (action.type) {
17    case "setUserDetails":
18      return { userDetails: action.payload };
19    default: {
20      throw new Error(`Unknown user action: ${action.type}`);
21    }
22  }
23}
24
25type UserProviderProps = { children: React.ReactNode };
26
27function UserProvider({ children }: UserProviderProps) {
28  const [state, dispatch] = useReducer(userReducer, {
29    userDetails: undefined,
30  });
31  const userDetails = { state, dispatch };
32  return (
33    <UserContext.Provider value={userDetails}>{children}</UserContext.Provider>
34  );
35}
36
37function useUserDetails() {
38  const context = React.useContext(UserContext);
39  if (context === undefined) {
40    throw new Error("useUserDetails must be used within a UserProvider");
41  }
42  return context;
43}
44
45export { UserProvider, useUserDetails };

So kannst Du einen Context definieren. Es ist ein bisschen ähnlich wie bei Redux: Du definierst den State, Reducer und die Actions.

Hier ein sehr einfaches Beispiel, wie man dispatch und state verwenden kann:

1const UserForm = () => {
2  const { state, dispatch } = useUserDetails();
3  const saveUserDetails = () => {
4    dispatch({
5      type: "setUserDetails",
6      payload: {
7        username: "updated user name",
8        firstName: "updated user name",
9        lastName: "updated last name",
10      },
11    });
12  };
13  return (
14    <>
15      <span>Username from Context: {state.userDetails?.username}</span>
16      <Button onClick={saveUserDetails}>Save User</Button>
17    </>
18  );
19};

Wenn man den Button „Save User” drückt, wird eine Aktion abgeschickt und der Username wird daraufhin aktualisiert.

1function App() {
2  return (
3    <>
4      <UserProvider>
5          <UserForm />
6          <UserAvatar />
7      </UserProvider>
8    </>
9  );
10}

useUserDetails darfst Du nur innerhalb des Providers verwenden. Und wie Du hier sehen kannst, brauchen die Child-Komponenten UserForm und UserAvatar keine Props mehr.

„Ok, ich verstehe das Pattern. Wie kann ich die asynchrone Aktionen abschicken? Gibt’s was Ähnliches wie Redux-Saga?”

Leider nicht. Du kannst aber so genannte Helper-Funktionen verwenden. Kent C. Dodds hat ein gutes Pattern vorgeschlagen, das solche Probleme löst und erklärt es in seinem Artikel .

3. Wie kann ich die umgesetzte Lösung erweitern und pflegen, sobald meine Anwendung größer und komplexer wird?

Mit diesen drei grundlegenden Regeln kannst Du Deine Anwendung flexibel erweitern und pflegen:

Nicht alles im Store speichern!

Wenn Du zum Beispiel Compound Components verwendest oder die Daten nur zwischen Parent und Child verteilst, solltest Du sie nicht im Store speichern. Es reicht, wenn Du in diesem Fall nur Props und den internen Komponenten-State verwendest.

Den State generisch definieren!

Stell Dir vor, Du hast in Deiner Anwendung verschiedene Typen von Modals oder Notifications, wie zum Beispiel ConfirmationModal, ReportProblemModal oder UserFormModal. Manche Modals gehören zu verschiedenen Features. Das bedeutet, Du kannst das Modal als eigenes Feature (Slice) speichern. Das wird Dir viel Zeit sparen.

Hier siehst Du ein Anti-Pattern. Der Code für das Modal ist unnötig wiederholt für jedes Feature. Das Gleiche gilt für die Tests: Du testest in diesem Fall mehrmals die gleiche Funktionalität (modalOpen).

1export interface UserState {
2  userFormModalOpen: boolean;
3}
4
5export interface ReportingState {
6  reportingModalOpen: boolean;
7}
8
9export interface ConfirmationState {
10  confirmationModalOpen: boolean;
11}

Stattdessen kannst Du den Code generisch definieren und vereinfachen. Das Gleiche gilt für Notifications, Errors und andere Dialogues.

1export interface ModalState {
2  isModalOpen: boolean;
3}

Den Komponenten-Baum besser strukturieren!

Bevor Du entweder zu Context oder Redux greifst, solltest Du erstmal überlegen, ob Compound Components Dein Problem lösen.

Compound Components kannst Du verwenden, wenn Du zwei Komponenten hast, die stark abhängig voneinander sind (Parent und Child). Das könnten zum Beispiel ein Dropdown oder eine MenuList sein:

1<MenuList open={isOpen} onClose={handleClose}>
2    <MenuItem onClick={goToProfile}>Profile</MenuItem>
3    <MenuItem selected={true}>My account</MenuItem>
4    <MenuItem>Logout</MenuItem>
5</MenuList>

MenuList ist ein Container für MenuItems. Die beiden Komponenten sind stark abhängig voneinander. Es ergibt keinen Sinn, MenuList ohne MenuItem zu verwenden und umgekehrt. MenuList hat einen internen State (isOpen) und weiß auch, welche Option selektiert ist.

Wenn Du mehr über Compound Components wissen willst, kannst Du diesen sehr guten Artikel von Kent C. Dodds lesen.

Fazit

In diesem Artikel habe ich drei Fragen über das State-Management gestellt und beantwortet. Bevor Du Dich für eine State-Management-Lösung entscheidest, solltest Du erstmal hinterfragen, ob Du überhaupt ein State-Management brauchst. Es kommt darauf an, wie groß und komplex Deine Anwendung ist und wie erfahren Du im Umgang mit React bist. Manchmal reichen nur die Props und State.

Wenn Du Dich doch für ein State-Management entscheidest, kannst Du entweder die built-in React Lösung verwenden (Context) oder Dir eine externe Library aussuchen (zum Beispiel Redux Toolkit).

Nichtsdestotrotz solltest Du auch die Best-Practices beachten:

  • Nicht alles im Store speichern und den State möglichst generisch definieren!
  • Den Komponenten-Baum besser strukturieren und zum Beispiel die Compound Components verwenden!

Ich hoffe, dass mein Artikel hilfreich für Dich war und alle Fragen beantwortet hat. Über Dein Feedback werde ich mich sehr freuen.

Quellen

1. Dokumention von React

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.