The Art of State: Zustandsmanagement in React-Anwendungen, Teil 1

Seite 3: Komplexer Zustand

Inhaltsverzeichnis

Beim Vergleichen des Custom Hook mit der PostEditor-Komponente fällt auf, dass beide Komponenten mehr als einen Zustand erzeugen, in dem sie mehrfach useState aufrufen. Allerdings gibt es – unabhängig vom fachlichen Inhalt – einen gravierenden Unterschied zwischen den Zuständen der beiden Komponenten: Im Fall des PostEditor handelt es um zwei völlig unabhängige Zustände. Je nachdem, in welches Feld Benutzer tippen, ändert das den einen oder anderen Zustand, ohne dass es einen fachlichen Bezug zum jeweils anderen Zustand gäbe.

Anders beim useApi-Hook: Hier sind die Teilzustände abhängig voneinander. Nach dem Laden der Daten ist zwingend der isLoading-Zustand neu zu setzen, um nicht in Inkonsistenzen zu geraten (Daten sind bereits geladen, isLoading verbleibt aber auf "true"). Noch deutlicher tritt die Abhängigkeit zutage, wenn nicht nur die Daten und isLoading gehalten werden sollen, sondern auch, ob beim Request ein Fehler auftrat, so wie in der folgenden Variante des Hooks:

function useApi(url) {
  const [ loading, setLoading ] = React.useState(false);
  const [ error, setError ] = React.useState(null);
  const [ data, setData ] = React.useState(null);
  
  React.useEffect( () => {
    setLoading(true);
    setError(null);
    fetch(url) 
     .then(res => res.json())
     .then(data => {
       setLoading(false);
       setData(data);
       setError(null)
  }).catch(e => { 
    setError(e); 
    setLoading(null); 
    setData(null)}
  }, [url]);

  return { loading, data, error };
}

Sind die Daten geladen, ist nicht nur isLoading zurückzusetzen, sondern auch der error-Zustand. Ebenfalls gilt: Bei einem Fehler muss man die Daten und isLoading zurücksetzen. Hier kann das Verwenden einzelner Zustände zu Problemen führen: So mag bei der Entwicklung "vergessen" werden, einen der Zustände entsprechend zurückzusetzen. Beim Start des Requests werden etwa die Daten nicht zurückgesetzt. Das kann zwar fachlich korrekt sein, vielleicht ist es aber auch ein Versehen.

Ein anderes Problem ist, dass React unter Umständen die Komponente, die den Hook verwendet, nach den einzelnen setter-Aufrufen erneut rendert: nach dem Erhalt der Daten zum Beispiel nach setLoading, nach setData und dann noch einmal nach setError. Das wiederum kann ein Performanceproblem darstellen, in jedem Fall aber würde die verwendete Komponente inkonsistente Daten erhalten: Nach dem ersten Aufruf wäre isLoading auf "false" gesetzt, allerdings wären die (neuen) Daten noch nicht im State enthalten. Somit würden entweder veraltete Daten angezeigt oder die Anwendung würde sogar fehlerhaft werden, da die Komponente nicht damit rechnet, nicht mehr zu laden, aber auch keine Daten zu haben (und keinen Fehler).

Für solche Fälle, in denen eher ein "Gesamtzustand" als mehrere "Teilzustände" vorliegt, sollte man nur einen Zustand mit useState erzeugen, der dann ein Objekt aufnimmt. Der Zustand lässt sich in der Folge "atomar" aktualisieren, sodass es zu keinen "fehlerhaften" Renderings kommt. Außerdem ist das zumindest in einigen Situationen weniger fehlerhaft. Im Gegensatz zur setState-Methode aus der React-Klassen-API setzt die von useState zurückgegebene setter-Funktion übrigens den ganzen State neu. Das heißt, was immer dieser Funktion übergeben wird, ist der neue Gesamtzustand. Zum Vergleich: setState hat alten und neuen Zustand zusammengeführt und nicht ersetzt.

Im Folgenden der useApi-Hook mit einem Gesamtzustand für den Lebenszyklus eines Server Request:

function useApi(url) {
  const [ apiState, setApiState ] =
    React.useState(
      { loading: false, data: null, error: null }
    );

    React.useEffect( () => {
      setApiState({loading: true});
      fetch(url) // vereinfacht
        .then(res => res.json())
        .then(data => setApiState({data: data}))
        .catch(err => setApiState({error: err}));
    }, [url]);

  return apiState;
}

Für den Fall, dass die Logik und damit der Zustand komplexer werden, gibt es eine Möglichkeit, mit dem useReducer-Hook die Verwaltung des Zustands aus der Komponente oder dem Custom Hook herauszuziehen. Dabei wird eine sogenannte Reducer-Funktion implementiert, in der die Logik zum Verarbeiten des Zustands untergebracht ist. React übergibt ihr den jeweils aktuellen Zustand sowie eine Aktion (Action). Auf Basis der beiden Informationen, von altem Zustand und Action erzeugt die Funktion neuen Zustand, den sie zurückliefert. Der neue Zustand wiederum wird dann an die verwendende Komponente zurückgegeben, sodass diese sich neu rendert, genau wie bei useState.

Eine Reducer-Funktion für den Lebenszyklus des Server Request aus dem Custom Hook useApi könnte wie folgt aussehen:

function apiReducer(oldState, action) {
  switch (action.type) {
    case "FETCH_START":
      return { ...oldState, loading: true, error: null };
    case "LOAD_FAILED":
      return { loading: false, error: action.error };
    case "LOAD_FINISHED":
      return { data: action.data };
    default:
    throw new Error("Invalid action!");
  }
}

Die Funktion verarbeitet drei Aktionen (FETCH_START, LOAD_FAILED, LOAD_FINISHED) und gibt jeweils neuen Zustand dafür zurück.

Es lässt sich darüber streiten, ob in dieser einfachen Form eine Reducer-Funktion den Code besser oder schlechter verständlich macht. In jedem Fall ist damit aber die Logik gänzlich aus dem React-Code (Hook bzw. Komponente) verschwunden. Denn bei einer Reducer-Funktion handelt es sich um eine reguläre, seiteneffektfreie JavaScript-Funktion, die sich auch in anderen Anwendungen oder mit anderen Bibliotheken einsetzen ließe. Selbst das Testen der Funktion ist einfacher, als eine ganze Komponente oder einen Custom Hook zu testen, da sie keinerlei Abhängigkeiten zu React hat.

Die Aktionen, die eine Reducer-Funktion verarbeiten kann, lassen sich frei definieren. Dabei handelt es sich um einfache JavaScript-Objekte, die eine type-Property enthalten (damit der reducer sie identifizieren kann) sowie – optional – einen fachlichen Payload, im Beispiel oben etwa die Fehlermeldung (bei Aktion LOAD_FAILED) beziehungsweise die neuen Daten (bei LOAD_FINISHED). Auch die Action[/code)-Objekte haben keine Abhängigkeit auf React oder Ähnliches und können zum Beispiel im Test einfach erzeugt werden. Der Verwender der Reducer-Funktion benutzt nun nicht mehr [code]useState, sondern den useReducer-Hook. Dieser erwartet dann die Reducer-Funktion und liefert jeweils den aktuellen Zustand sowie eine Funktion zum Verschicken von Aktionen an die Reducer-Funktionen (dispatch) zurück. Damit ist die API ähnlich wie bei useState: Sie gibt Zustand sowie eine Funktion zum Ändern des Zustands zurück, nur dass der Zustand nicht mehr direkt verändert, sondern eine Aktion ausgelöst wird. Der Custom Hook useApi kann den Reducer somit wie folgt verwenden:

function apiReducer(. . .) { wie oben gezeigt }

function useApi(url) {
  const [apiState, dispatch] = React.useReducer(apiReducer);
  
  React.useEffect( () => {
    dispatch({ type: "FETCH_START" });
    fetch(...)
      .then(
        data => dispatch({type: "LOAD_FINISHED", data: data })
      )
  }, [url]);

  return apiState;
}

In welchen Fällen Reducer-Funktionen wirklich Sinn ergeben, ist von Fall zu Fall zu entscheiden. Die Vorteile (Logik an einer Stelle konzentriert, keine React-Abhängigkeit, einfache Testbarkeit) überwiegen die Nachteile (relativ unschöner Code zum Dispatch der Actions, Arbeiten mit immutable-Objekten im Reducer) am ehesten, wenn der Reducer komplexe Logik zu verarbeiten hat. Die Reducer-Funktionen sind übrigens API-kompatibel mit denen aus Redux, sodass sich eine Reducer-Funktion auch mit Redux verwenden ließe. Der nächste Teil dieses Artikels wird sich näher mit Redux beschäftigen.