Startseite / Artikel

Redux Toolkit von First Slice bis zu asynchronen Daten: Ein funktionsfähiges mentales Modell

Erfahren Sie, wie sich der Store von Redux Toolkit, die Slices, die mit Immer angetriebenen Reducer, die Selectoren sowie createAsyncThunk mithilfe eines Zählers, eines Warenkorbs und einer Produktliste zusammenfügen.

Verlag: REACTAPP.TOP9 Min. Lesezeit2008 Wörter

Quellen: Redux Toolkit: A Complete Guide from Basics to Production

Sobald eine React-Anwendung wächst, verteilt sich der gemeinsame Zustand – wie Benutzerdaten, Warenkorb, Einstellungen, Filter und API-Daten – auf viele Screens, wodurch das Übergeben all dieser Daten über Props anfällig wird. Redux löst dieses Problem mit einem zentralen Store, und Redux Toolkit (RTK) ist der empfohlene Ansatz zur Erstellung von Redux-Anwendungen heute. Diese Anleitung führt Sie von einem einfachen Counter bis hin zu einem Warenkorb mit berechneten Gesamtbeträgen sowie einer durch die API gestützten Liste, damit Sie RTK-Code selbstbewusst lesen und schreiben können.

Zuerst sollten Sie überprüfen, ob ein Store überhaupt notwendig ist: Allein das Verwenden von Props rechtfertigt nicht die Installation von Redux.

Die Kernidee hinter Redux

Redux speichert den gemeinsamen Zustand in einem einzigen Store und ändert ihn nur durch Aktionen, die von Reduzern verarbeitet werden, sodass jede Aktualisierung vorhersehbar ist. Ohne Redux gelangen Daten jeweils nur über eine Ebene von Props weiter.

Component
   ↓
Props
   ↓
Component
   ↓
Props
   ↓
Component

Durch den Store lesen die Komponenten das, was sie benötigen, und senden Aktionen, um Änderungen anzufragen.

             Redux Store
                  ↑
                  │
        ┌─────────┴─────────┐
        │                   │
    Component A         Component B
        │                   │
        └──── dispatch ─────┘

Was Redux Toolkit hinzufügt

RTK bündelt Hilfsfunktionen für die gängigen Redux-Aufgaben.

Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query

Klassisches Redux verteilt eine Funktion auf viele Dateien.

Action Types
Action Creators
Reducers
Constants
Store
Middleware

createSlice() ermöglicht es, den größten Teil davon an einem Ort zu definieren.

Wie Daten in einer RTK-App fließen

Komponenten lesen mit useSelector() und senden Aktionen mit useDispatch(); Reducer erzeugen neuen Zustand.

                 React Application
                        │
                        ↓
                 useSelector()
                        │
                        ↓
                 Redux Store
                        ↑
                        │
                 useDispatch()
                        ↑
                        │
                    Actions
                        │
                        ↓
                    Reducer
                        │
                        ↓
                   New State

Der wesentliche Kreislauf:

UI
 ↓
dispatch(action)
 ↓
Reducer
 ↓
State update
 ↓
Subscribed components
 ↓
UI re-render

Installation und Erstellung des Stores

Installieren Sie zwei Pakete:

npm install @reduxjs/toolkit react-redux

@reduxjs/toolkit enthält die Redux-APIs; react-redux verbindet sie mit React.

useSelector()
useDispatch()
Provider

Die Beispielmethode zur Anzeige von Zählwerten ist nach Funktionalitäten strukturiert.

src/
│
├── app/
│   └── store.js
│
├── features/
│   └── counter/
│       └── counterSlice.js
│
├── App.jsx
└── main.jsx

configureStore() nimmt ein Mapping von Slice-Reduktor:innen entgegen; jeder Schlüssel wird zu einem obersten Zustandszweig.

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

Der resultierende Zustand:

{
  counter: {
    value: 0
  }
}

Slices: eine Funktion, eine Datei

Ein Slice verantwortet einen logischen Teil des Zustands, und Apps haben in der Regel mehrere davon.

authSlice
cartSlice
productSlice
userSlice
notificationSlice

Der Counter-Slice deklariert einen Namen, den Anfangszustand sowie einen Reduktor pro Operation und exportiert anschließend die erzeugten Action-Creator:innen sowie den Reduktor.

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  value: 0,
};

const counterSlice = createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },

    decrement(state) {
      state.value -= 1;
    },

    incrementByAmount(state, action) {
      state.value += action.payload;
    },
  },
});

export const {
  increment,
  decrement,
  incrementByAmount,
} = counterSlice.actions;

export default counterSlice.reducer;

Ein einziger Aufruf ersetzt drei manuell geschriebene Teile.

Action Types
     +
Action Creators
     +
Reducer

Was createSlice erzeugt

Nehmen wir einen minimalen Slice:

createSlice({
  name: "counter",

  initialState,

  reducers: {
    increment(state) {
      state.value += 1;
    },
  },
});

RTK verbindet den Slice-Namen mit dem Schlüssel des Reduktors zu einem Action-Typ

"counter/increment"

und erstellt einen Action-Creator.

increment()

Daher ist dispatch(increment()) äquivalent dazu, Folgendes abzusenden:

dispatch({
  type: "counter/increment",
});

Verbindung des Stores mit React

In main.jsx macht der Provider den Store für jede darunterliegende Komponente verfügbar.

import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";

import App from "./App";
import { store } from "./app/store";

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Lesen mit useSelector

useSelector() wählt genau den Teil des Zustands aus, den eine Komponente benötigt.

import { useSelector } from "react-redux";

function Counter() {
  const count = useSelector(
    state => state.counter.value
  );

  return <h1>{count}</h1>;
}

Die Komponente wird nur dann neu gerendert, wenn sich dieser Wert ändert; siehe wie React-Redux entscheidet, neu zu rendern.

Schreiben mit useDispatch

useDispatch() gibt den dispatch-Funktion des Stores zurück.

import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";

function Counter() {
  const dispatch = useDispatch();

  return (
    <button onClick={() => dispatch(increment())}>
      Increment
    </button>
  );
}

Ein Klick verläuft dann wie folgt:

Button Click
     ↓
dispatch(increment())
     ↓
Action
     ↓
Reducer
     ↓
State changes
     ↓
useSelector detects relevant change
     ↓
Component re-renders

Daten über payload übergeben

Aktionen tragen Daten in payload mit sich, die aus dem zweiten Argument des Reducers stammen:

incrementByAmount(state, action) {
  state.value += action.payload;
}

Der Aufrufer übermittelt den Wert:

dispatch(incrementByAmount(10));

wodurch diese Aktion erzeugt wird:

{
  type: "counter/incrementByAmount",
  payload: 10
}

Jede Aktion hat eine type und optional eine payload.

action
│
├── type
└── payload

Warum hier die Mutierung des Zustands erlaubt ist

Eine klassische Vorstellungsfrage: Dieser Reducer scheint den Zustand zu mutieren, was Redux verbietet.

increment(state) {
  state.value += 1;
}

RTK führt Reducers über Immer aus, welches einen Entwurfsproxy überlässt, Änderungen aufzeichnet und ein neues, unveränderliches Objekt erzeugt.

Reducer
   ↓
Immer Proxy
   ↓
Track changes
   ↓
Create new immutable state

Man erhält lesbaren Code, ohne die Unveränderlichkeit zu verletzen. Entweder modifiziert man den Entwurf oder gibt einen neuen Wert zurück – niemals beides – und mutiert außerhalb der RTK-Reducers nicht auf diese Weise.

Ein Teil des Warenkorbs

Ein E-Commerce-Warenkorb beginnt leer.

const initialState = {
  items: [],
};

Der Slice fügt Elemente hinzu, entfernt sie und leert den Warenkorb; removeItem weist einen neuen Array zu, den Immer ebenfalls akzeptiert.

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",

  initialState,

  reducers: {
    addItem(state, action) {
      state.items.push(action.payload);
    },

    removeItem(state, action) {
      state.items = state.items.filter(
        item => item.id !== action.payload
      );
    },

    clearCart(state) {
      state.items = [];
    },
  },
});

export const {
  addItem,
  removeItem,
  clearCart,
} = cartSlice.actions;

export default cartSlice.reducer;

Ein Element hinzufügen

Gegeben ist ein Produktobjekt:

const product = {
  id: 101,
  name: "Laptop",
  price: 60000,
};

Ein Klick-Handler leitet es weiter:

const dispatch = useDispatch();

const handleAddToCart = () => {
  dispatch(addItem(product));
};

und das gesamte Produkt wird zum Payload.

{
  type: "cart/addItem",

  payload: {
    id: 101,
    name: "Laptop",
    price: 60000
  }
}

Dann legt der Reducer es in:

state.items

Einen Eintrag entfernen

Entfernung nach ID filtern:

removeItem(state, action) {
  state.items = state.items.filter(
    item => item.id !== action.payload
  );
}

Die Komponente übermittelt nur die ID:

dispatch(removeItem(101));

also innerhalb des Reducers:

action.payload === 101

Werte ableiten anstelle von deren Speicherung

Nehmen wir an, der Warenkorb enthält diese Einträge:

[
  {
    price: 100,
    quantity: 2,
  },
  {
    price: 50,
    quantity: 3,
  }
]

Der Gesamtbetrag ergibt sich direkt daraus:

100 × 2 + 50 × 3
= 200 + 150
= 350

Es ist verlockend, den Gesamtbetrag neben den Einträgen zu speichern:

{
  items,
  total
}

doch dann muss jeder Reducer beides synchron halten. Berechnen Sie ihn lieber direkt:

const selectCartTotal = state =>
  state.cart.items.reduce(
    (total, item) =>
      total + item.price * item.quantity,
    0
  );

und lesen Sie ihn anschließend ab:

const total = useSelector(selectCartTotal);

Speichern Sie die Quelle der Wahrheit; leiten Sie alles, was berechnet werden kann, daraus ab.

Selektoren als einziger Zugriffspunkt

Ein Selektor ist eine Funktion, die den Zustand liest.

const selectCartItems = state =>
  state.cart.items;

Die Komponenten verwenden es anstelle davon, die Zustandsstruktur zu kennen.

const items = useSelector(selectCartItems);

Andernfalls wiederholt sich dieser Pfad überall:

state.cart.items

Mit einem Selektor betrifft die Umstrukturierung nur eine Funktion.

Memoisieren mit createSelector

createSelector() speichert ein abgeleitetes Ergebnis, bis sich seine Eingaben ändern.

import { createSelector } from "@reduxjs/toolkit";

const selectCartItems = state =>
  state.cart.items;

export const selectCartTotal = createSelector(
  [selectCartItems],

  items =>
    items.reduce(
      (total, item) =>
        total + item.price * item.quantity,
      0
    )
);

Die Logik ist einfach:

Input unchanged
      ↓
Return cached result

Input changed
      ↓
Recalculate

Dies hilft bei teuren Berechnungen sowie bei Selektoren, die neue Arrays oder Objekte zurückgeben – andernfalls würden diese bei jeder Store-Änderung neu gerendert.

Daten laden mit createAsyncThunk

Apps rufen APIs wie folgt auf:

GET /api/products

Eine Anfrage geht von „idle“ über „loading“ bis zu „success“

idle
 ↓
loading
 ↓
success

oder zu einem Fehler.

idle
 ↓
loading
 ↓
error

createAsyncThunk() modelliert dies: Geben Sie ihm einen Typpräfix sowie eine asynchrone Funktion; das Werfen eines Fehlers markiert, dass die Anfrage fehlgeschlagen ist.

import {
  createAsyncThunk,
  createSlice,
} from "@reduxjs/toolkit";

export const fetchProducts = createAsyncThunk(
  "products/fetchProducts",

  async () => {
    const response = await fetch("/api/products");

    if (!response.ok) {
      throw new Error("Failed to fetch products");
    }

    return response.json();
  }
);

Es erzeugt drei Lebenszyklus-Aktionen:

fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected

Behandlung des Lebenszyklus in einem Slice

Der Products-Slice überwacht Elemente, Status und Fehler und aktualisiert diese in extraReducers.

const productsSlice = createSlice({
  name: "products",

  initialState: {
    items: [],
    status: "idle",
    error: null,
  },

  reducers: {},

  extraReducers: builder => {
    builder

      .addCase(
        fetchProducts.pending,
        state => {
          state.status = "loading";
        }
      )

      .addCase(
        fetchProducts.fulfilled,
        (state, action) => {
          state.status = "success";
          state.items = action.payload;
        }
      )

      .addCase(
        fetchProducts.rejected,
        (state, action) => {
          state.status = "failed";
          state.error =
            action.error.message;
        }
      );
  },
});

Reducers versus extraReducers

reducers definieren die eigenen Aktionen des Slices zusammen mit den dazugehörigen Erstellern.

reducers: {
  addItem() {}
}

extraReducers reagiert auf Aktionen, die an anderer Stelle definiert wurden, wie zum Beispiel solche von createAsyncThunk():

pending
fulfilled
rejected

Der Slice hört sich lediglich diese Aktionen an.

Auslösen des Thunks aus einer Komponente

Starten Sie die Anfrage beim Laden der Komponente:

const dispatch = useDispatch();

useEffect(() => {
  dispatch(fetchProducts());
}, [dispatch]);

In Strict Mode werden Effekte in der Entwicklung zweimal ausgeführt, daher sind dort zwei Anfragen zu erwarten. Wählen Sie anschließend den Zustand

const {
  items,
  status,
  error,
} = useSelector(
  state => state.products
);

und rendern Sie nach Status:

if (status === "loading") {
  return <p>Loading...</p>;
}

if (status === "failed") {
  return <p>{error}</p>;
}

return (
  <div>
    {items.map(product => (
      <div key={product.id}>
        {product.name}
      </div>
    ))}
  </div>
);

Für aufwändigere Datenabfragen kann RTK Query dieses Muster ersetzen.

Middleware und configureStore

Middleware befindet sich zwischen dispatch() und dem Reducer.

dispatch(action)
      ↓
   Middleware
      ↓
   Reducer
      ↓
 New State

Häufige Anwendungsfälle:

  • Asynchrone Logik
  • Protokollierung
  • Analysen
  • Authentifizierungsabläufe
  • Nebeneffekte
  • Abschirmung von Aktionen

configureStore() ersetzt die manuelle Konfiguration, fügt Standard-Middleware (einschließlich Thunks) sowie Redux DevTools hinzu.

const store = configureStore({
  reducer: {
    auth: authReducer,
    cart: cartReducer,
    products: productsReducer,
  },
});

Kernpunkte

  • Eine Slice bündelt den Zustand, Reducer sowie generierte Aktionsschöpfer für eine bestimmte Funktion.
  • Immer macht Reducer im Mutationsschema sicher, allerdings nur innerhalb von RTK-Reduzern.
  • Speichern Sie nur minimalen Zustand; den Rest ermitteln Sie mithilfe von Selectoren, wobei diese bei aufwändigen Berechnungen gememorisert werden sollten.
  • createAsyncThunk() in Kombination mit extraReducers modelliert Lade-, Erfolgs- und Fehlerfälle.
  • Die Standardeinstellungen von configureStore() decken die meisten Anwendungen ab.