Das beste React State Management auswählen

oder: Sollte ich meinen State mit Context-API und State-Hooks oder Redux managen?

Dieser Artikel erschien ursprünglich im Entwickler.de-Magazin

Wenn man sich in der Community umschaut, dann trifft man immer wieder auf folgende Fragestellung: Wie manage ich meinen State in React?[1]n00b Q: Why would you ever use React w/o Hooks?

Speziell Devs, die vorher mit anderen Frameworks wie Angular oder Vue gearbeitet haben, fragen sich, wie sie ihren State in React managen sollen. Häufig kommt die Frage nach einer offiziellen Lösung für das State-Management.[2]State management in React?

Eine weitere Frage die sich Devs seit der Einführung der Context-API und der Hooks-API in React 16.8 stellen, ist die Entscheidung zwischen Redux oder der Context-API mit useState/useReducer, um seinen globalen State zu managen.[3]Is redux really a good idea?

Beide Fragestellung möchte ich im Folgenden beantworten. Dazu habe ich in der Community recherchiert und mir die Meinung vieler erfahrener React-Entwickler eingeholt.

Was ist State Management?

Bevor wir anfangen, möchte ich aber noch Grundsätzliches klären. Bevor man die Frage nach dem richtigen State-Management lösen kann, benötigen wir meines Erachtens eine Definition was denn genau State-Management bedeutet.

Der State oder zu Deutsch Status beschreibt den Zustand einer Anwendung zu einem gegebenen Zeitpunkt.

Unterarten des States sind:

  • Server-State,
  • Navigation-State
  • lokaler UI-State
  • globaler UI-State

Dementsprechend beschreibt State-Management die Pflege des Zustands/Wissens einer Anwendung in Abhängigkeit aller Inputs.[4]Virdol, Martin – How To Simplify Your Application State Management 

Inputs finden in der Regel auf dem Server (API) oder dem Client (User) statt.

Die Schwierigkeit des State-Managements ergibt sich demnach aus der Koordination aller Unterarten des States einer Anwendung.[5]Virdol, Martin – How To Simplify Your Application State Management 

Was bedeutet State in React?

Die UI ist die optische Wiedergabe des States einer Anwendung. Wie oben beschrieben, repräsentiert der State den Zustand einer Anwendung zu einem gegebenen Zeitpunkt.[6]Wieruch, Robin -React State Management

Daraus folgt: In React der State ist eine Datenstruktur, die den aktuellen Zustand der UI widerspiegelt.

Der State kann dabei aus verschiedenartigen Daten bestehen:

  1. Ein Boolean, das entscheidet, ob eine Sidebar geöffnet ist oder nicht.
  2. Der Text-Inhalt eines Formulars.
  3. Server-Daten, die über eine API gezogen wurden.

In JavaScript können wir dies wie folgt darstellen:

// 1)
let isOpen = false;

// 2)
let addressForm = {
  first: "Donald", 
  last: "Duck",
  street: "Webfoot Walk",
  no: "1313",
  town: "Duckburg",
  state: "Calisto",
};

// 3)
const issues = [
  {
    "url": "https://api.github.com/repos/rockiger/metado/issues/47",
    "number": 47,
    "title": "Look into warnings from react beautiful dnd",
    "body": "",
    ...
  },
  ...
  {
    "url": "https://api.github.com/repos/rockiger/metado/issues/46",
    "number": 46,
    "title": "Create Testsystem for metado",
    "body": "- [ ] https://firebase.google.com/docs/emulator-suite\n",
    ...
  },
]

Alle diese Daten könnten entweder lokal (d.h. innerhalb einer Komponente mit React-Hooks oder setState) oder global gemanagt werden.

In diesem Fall bedeutet managen, das Speichern und Verändern des Status, sowie die Darstellung des Status durch die UI.

Gibt es eine offizielle / empfohlene State-Management-Lösung wie bei anderen Frameworks in React?

Jein. Es gibt mit der setState-Methode und den useState/useReducer-Hooks eine React-eigene Lösung für das lokale State Management innerhalb einer Komponente. Damit kann man schon einigermaßen komplexe Apps erstellen[7]Should I use a state management library like Redux or MobX?.

Eine quasi-offizielle Lösung für das globale State-Management wie NgRx für Angular oder Vuex für Vue gibt es nicht. Hier hat man bei React die Qual der Wahl. Manche Lösungen sind auf bestimmte Teilbereiche des Application-State spezialisiert (z.B. react-query), manche sind allgemeine Lösungen (z.B. Redux).[8]Erikon, Mark – When (and when not) to reach for Redux

Wie wählt man jetzt die passende State-Management-Lösung aus?

Navigation-State möchte ich aus dieser Betrachtung bewusst heraus lassen. Jede React-Anwendung die mehr als zwei Ansichten hat, sollte auf eine Routing-Bibliothek wie react-router setzen. Das Handling der Browser-API ist aus meiner Sicht viel zu aufwendig, um es selbst zu managen.

Wenn wir davon ausgehen, dass die Hauptschwierigkeit die Koordination der Unterarten ist, dann sollten wir uns als Erstes fragen: Welche Arten von State müssen wir in unserer (React)-Anwendung verwalten? Wie komplex ist mein State? Wie oft ändert er sich? Und dann entscheiden wir welche Lösung für uns am besten ist.

Unten seht ihr ein Diagramm meines Entscheidungsprozesses.

Entscheidungsprozess zur Auswahl des State-Management

useState

Als Erstes fragen wir uns: Wird unser State von mehr als zwei Komponenten geteilt? Wenn wir das verneinen, ist die Frage: Hat unser State eine komplexe Update-Logik, d.h. ist der neue State abhängig vom alten State oder werden mehrere Unterwerte geändert? Wenn wir auch, dies verneinen nutzen wir lokalen State mit useState.

Hat man beispielsweise einen simplen Zähler mit einer Komponente, reicht die Verwendung von lokalen State mit useState aus.

function Counter({initialCount: 42}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
  );
}

Beim ersten Rendern der Counter-Komponente wird der useState-Hook mit intialCount initialisiert. Als Rückgabe bekommen wir die State-Variable count und die Update-Funktion setCount.

Um count zu ändern, müssen wir setCount mit einem neuen Wert aufrufen. Wann immer wir setCount aufrufen, wird die Counter-Komponente neu gerendert.

Wenn wir den neuen Wert von count auf Basis des alten setzen wollen, sollten wir setCount eine simple Update-Funktion übergeben, die als Rückgabewert den neuen Wert von count übergibt.

Aufgaben

useReducer

Wenn unser State nun aber im Gegensatz zu unserem obigen Beispiel eine komplexe Update-Logik hat, ist es sinnvoll useReducer zu verwenden.

Wie oben schon beschrieben bedeutet komplex in diesem Zusammenhang, das eine Änderung des State viele Werte des State ändern muss oder, dass der State vom vorherigen State abhängig ist.

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'set':
      return {count: action.count};
    case 'reset':
      return initialState;
    default:
      state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'reset'})}>Reset</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'set', count: 25})}>Set to 25</button>
    </>
  );
}

Zuerst definieren wir einen initialen State und eine pure Reducer-Funktion. Diese konsumiert einen State und eine Aktion (engl. Action) und gibt einen neuen State zurück.

Um useReducer in unsere Zähler-Komponente zu verwenden, gehen wir ähnlich vor wie mit useState: Wir initialisieren useReducer mit initalState und reducer. Nach der Initialisierung bekommen wir den State und eine Dispatch-Funktion (engl. Versand) zurück. Die Dispatch-Funktion dient dazu den Reducer mit einer State-Änderung zu beauftragen. Dazu rufen wir dispatch mit einer entsprechenden Aktion auf.

Dementsprechend enthalten die onClick-Handler der Buttons auch keine eigene Logik mehr, sondern fragen die State-Änderung nur mittels dispatch an.

Aufgaben

Context-API + State-Hooks

Wenn wir uns den rechten Teil des Entscheidungsdiagramms anschauen, also die Frage „Wird der State von mehr als zwei Komponenten geteilt?“ mit Ja beantwortet haben, stehen wir vor der Frage, ob unser globaler State sich häufig ändert? Können wir diese verneinen, kann uns die Context-API das Leben erleichtern.

Ein typisches Beispiel ist der Login-Status eines Nutzers. Der in vielen unterschiedlichen Komponenten benötigt wird.

// Auth.js
const AuthContext = createContext({username: '', role: ''});

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within a AuthProvider');
  }
  return context;
}

export function AuthProvider({ children }) {
  const [user, setUser] = useState({username: '', role: ''});

  return (
    <AuthContext.Provider value={{ user }}>
      {children}
    </AuthContext.Provider>
  );
}

// App.js
export function App() {
  return (
    <AuthProvider>
      <Component />
    </AuthProvider>
  );
}

// Component.js
export function Component() {
  const { user } = useAuth()
  if (user?.role === 'ADMIN') {
    return <AdminView />
  } else {
    return <UserView />
  }
}

In unserem Code-Beispiel initialisieren wir den Context mit createContext, den wir allen Kindern der Komponente AuthProvider zur Verfügung stellen.

Mit dem Custom-Hook useAuth können die Kind-Komponenten auf den State zugreifen. Dadurch können wir uns unnötiges Prop-Drilling ersparen.

Aufgaben

Lies mehr über Reacts useContext Hook in der React-Doku
Lies mehr über useContext kombiniert mit useState und useReducer

Server-State (react-query, Apollo, swr)

Wenn wir wieder unser Entscheidungsdiagramm anschauen, sehen wir, dass wir die Board-Mittel von React aufgebraucht haben. Wir begeben uns jetzt in den Bereich, in dem es sich lohnen kann externe State-Management-Lösungen zu verwenden.

Wenn eine Anwendung globalen State hauptsächlich dafür nutzt, um Daten die auf einem Server liegen, abzurufen, zu cachen und zu aktualisieren, empfiehlt sich eine spezialisierte Lösung zum Daten-Management zu verwenden.[9]Dodds, Kent – Application State Managment with React

In einer Admin-Oberfläche müssen wir häufig viele unterschiedliche Daten in unterschiedlichen Ansichten anzeigen. Diese Daten effizient zu managen, ist alles andere als trivial.[10]Overview react-query

Im Folgenden möchte ich ein einfaches Beispiel mit react-query zeigen.

// App.js
import { QueryClient, QueryClientProvider} from 'react-query'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Component />
    </QueryClientProvider>
  )
}

// Component.js
import { useQuery } from 'react-query'

function Component() {
  const { isLoading, error, data } = useQuery('issues', () =>
    fetch('https://api.github.com/repos/rockiger/metado').then(res =>
      res.json()
    )
  )

  if (isLoading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
    </>
  );
}

Ähnlich wie bei unserem Context-API-Beispiel stellt uns react-query einen Context-Provider und einen Custom-Hook zur Verfügung.

Mit dem Custom-Hook useQuery können wir nun, die Daten, die wir benötigen fetchen. Dafür übergeben wir useQuery einen eindeutigen Namen und die eigentliche Fetch-Funktion. Wir bekommen dafür, die Daten (Status, Fehler, Inhalt) die wir zur Anzeige benötigen zurück.

Der eigentliche Clou ist, das react-query für uns das Management übernimmt. Es sorgt dafür, dass die Daten gecacht und automatisch aktualisiert werden. Wenn wir dieselbe Abfrage issues in einer zweiten Komponente verwenden, wird react-query die Abfrage nicht mehrmals ausführen, sondern auf die gecachten Daten zurückgreifen.[11]Important Defaults – react-query

Darüber hinaus ist der bereitgestellte Context-Provider optimiert unnötiges Neu-Rendern der Komponenten zu verhindern. Diese Funktionalität selbst zu implementieren ist alles andere als einfach.[12]On Cache Invalidation – Why is it hard?

Aufgaben

Redux et al.

Was aber macht man, wenn sich der State von mehreren Komponenten geteilt wird, sich häufig ändert und nicht hauptsächlich aus Server-State besteht?

Zum Beispiel um den State in einem Text-Editor mit vielen Schaltflächen zu managen. Hier kommt Redux ins Spiel. Redux ist sehr effizient, hat ein großes Ökosystem mit vielen Plugins (sog. Middleware) und ist sehr gut dokumentiert.

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { createSlice } from '@reduxjs/toolkit'

// 1) Create state and actions/reducers
const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    incremented: (state) => {
      return {value: state.value + 1};
    },
    decremented: (state) => {
      return { value: state.value - 1 };
    },
  },
})

export const { incremented, decremented } = counterSlice.actions

// 2) select and dispatch
export const Count = () => {
  const count = useSelector(state => state.counter.count)
  const dispatch = useDispatch()

  return (
    <main>
      <div>Count: {count}</div>
      <button onClick={() => dispatch(incremented())}>Decrement</button>
      <button onClick={() => dispatch(decremented())}>Increment</button>
    </main>
  )
}

Wir reimplementieren hier wieder unser Zähler-Beispiel mithilfe von Redux und dem Redux-Toolkit. Es ist der useReducer-Variante sehr ähnlich.

Zuerst initialisieren wir unseren State und unsere Reducer. In unserer Counter-Komponente initialisieren wir einen sogenannten Selector; eine Funktion, um einen Teilbereich des State zu auswählen. Zusätzlich nutzen wir noch den useDispatch-Hook von Redux, um State-Änderungen beauftragen zu können.

Im Unterschied zu unserer useReducer-Variante müssen wir eine Action nicht mit einem String benennen. Action-Namen werden durch createSlice automatisch erzeugt.

Wie unser simples Zähler-Beispiel zeigt, erfordert die Einrichtung von Redux schon etwas mehr Aufwand. Sodass das sich der Aufwand seit der offiziellen Einführung der Context-API in React 16.3 für kleine Anwendung meistens nicht lohnt.

Die Vorteile von Redux gegenüber der Kombination aus Context-API und React-Hooks sind:[13]Erikson Marc – Blogged Answers: Redux – Not Dead Yet!

  • konsistente und definiert Architekturmuster, die die Arbeit im Team erleichtern können
  • einfaches Debugging durch die exzellente Browser-Erweiterung
  • der Einsatz von unterschiedlicher Middleware
  • viele Add-ons und eine gute Erweiterbarkeit
  • die Plattform- und Framework-übergreifende Nutzung
  • je nach App-State eine bessere Performance als die Context-API

Redux ist zwar die bekannteste und populärste State-Management-Lösung für React, aber beileibe nicht die einzige. Alternativen sind unter anderem: MobX, Recoil, RxJS.

Aufgaben

Fazit

Es muss nicht immer Redux sein. Viele React-Anwendungen kommen ohne Redux aus. In vielen Fällen führt der Einsatz von Redux zu unnötiger Komplexität.

React-Hooks und die Context-API können viele Anwendungsfälle abdecken, die früher mit Redux gelöst wurden

Für das Caching von Server-State empfiehlt sich eine spezialisierte Bibliothek.

Das Entscheidungsdiagramm kann Entwicklern helfen, die richtige Form des State-Managements für ihre Anwendung zu finden.

Abschließend sei noch gesagt, dass die gezeigten Lösungsansätze sich nicht gegenseitig ausschließen. Es spricht nichts dagegen lokalen und globalen State in seiner Anwendung zu verwenden.