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.
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 mitextraReducersmodelliert Lade-, Erfolgs- und Fehlerfälle.- Die Standardeinstellungen von
configureStore()decken die meisten Anwendungen ab.