Redux Toolkit od First Slice do danych asynchronicznych: praktyczny model mentalny
Dowiedz się, jak sklepy Redux Toolkit, jego fragmenty, reduktory oparte na Immer, selektory oraz createAsyncThunk współpracują ze sobą, korzystając z licznika, koszyka zakupów i listy produktów.
Źródła: Redux Toolkit: A Complete Guide from Basics to Production
Gdy aplikacja React rośnie, stan współdzielony, takie jak informacje o użytkowniku, koszyk zakupów, ustawienia preferencji, filtry oraz dane API, rozprzestrzenia się na wielu ekranach, a przekazywanie ich wszystkich za pomocą propów staje się nietrwałe. Redux rozwiązuje ten problem dzięki centralnemu sklepowi danych, a Redux Toolkit (RTK) jest obecnie zalecanym sposobem na tworzenie aplikacji z użyciem Redux. Ten przewodnik pokazuje drogę od prostego mechanizmu liczenia do koszyka zakupów z obliczanymi łącznymi kwotami oraz listy generowanej na podstawie danych API, abyś mógł z pewnością czytać i pisać kod w RTK.
Najpierw upewnij się, czy w ogóle potrzebujesz sklepu danych: sam fakt korzystania z prop drilling nie jest powodem do instalacji Redux.
Główna idea Redux
Redux przechowuje stan współdzielony w jednym sklepie danych i zmienia go wyłącznie poprzez działania obsługiwane przez reduktory, dzięki czemu każda aktualizacja jest przewidywalna. Bez niego dane są przekazywane warstwa po warstwie za pomocą propów.
Component
↓
Props
↓
Component
↓
Props
↓
Component
Dzięki sklepowi komponenty odczytują to, czego potrzebują, i wysyłają działania w celu poproszenia o zmiany.
Redux Store
↑
│
┌─────────┴─────────┐
│ │
Component A Component B
│ │
└──── dispatch ─────┘
Czym dodaje Redux Toolkit
RTK zawiera narzędzia do typowych zadań w Redux.
Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query
Klasyczny Redux rozprosza jedną funkcjonalność między wieloma plikami.
Action Types
Action Creators
Reducers
Constants
Store
Middleware
createSlice() umożliwia przedstawienie większości z tych elementów w jednym miejscu.
Jak dane przepływają w aplikacji RTK
Komponenty odczytują dane za pomocą useSelector(), a działania wysyłają za pomocą useDispatch(); reduktory tworzą nowy stan.
React Application
│
↓
useSelector()
│
↓
Redux Store
↑
│
useDispatch()
↑
│
Actions
│
↓
Reducer
│
↓
New State
Zasadniczy cykl:
UI
↓
dispatch(action)
↓
Reducer
↓
State update
↓
Subscribed components
↓
UI re-render
Instalacja i tworzenie sklepu
Należy zainstalować dwa pakiety:
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit zawiera interfejsy Redux; react-redux łączy je z React.
useSelector()
useDispatch()
Provider
Przykładowa aplikacja licznika jest zorganizowana według funkcjonalności.
src/
│
├── app/
│ └── store.js
│
├── features/
│ └── counter/
│ └── counterSlice.js
│
├── App.jsx
└── main.jsx
configureStore() przyjmuje mapę reduktorów typu slice; każdy klucz staje się gałęzią stanu najwyższego poziomu.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
Otrzymany stan:
{
counter: {
value: 0
}
}
Slices: jedna funkcjonalność, jeden plik
Jeden slice odpowiada za jedną logiczną część stanu, a aplikacje zazwyczaj mają ich kilka.
authSlice
cartSlice
productSlice
userSlice
notificationSlice
Slice do licznika deklaruje nazwę, stan początkowy oraz jednego reduktora na każdą operację, a następnie eksportuje utworzone funkcje tworzące akcje oraz reduktora.
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;
Jedna wywołanie zastępuje trzy ręcznie napisane elementy.
Action Types
+
Action Creators
+
Reducer
To, co generuje createSlice
Weźmy minimalny slice:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK łączy nazwę slice i klucz reduktora w typ akcji
"counter/increment"
i tworzy funkcję tworzącą akcje.
increment()
Zatem dispatch(increment()) jest równoważne wysłaniu:
dispatch({
type: "counter/increment",
});
Podłączanie sklepu do React
W pliku main.jsx komponent Provider umożliwia dostęp do sklepu danych każdemu komponentowi znajdującemu się poniżej niego.
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>
);
Czytanie danych za pomocą useSelector
Funkcja useSelector() wybiera dokładnie tę część stanu, która jest potrzebna danemu komponentowi.
import { useSelector } from "react-redux";
function Counter() {
const count = useSelector(
state => state.counter.value
);
return <h1>{count}</h1>;
}
Komponent jest renderowany na nowo tylko wtedy, gdy ta wartość ulega zmianie; patrz jak React-Redux decyduje o ponownym renderowaniu.
Zapisywanie danych za pomocą useDispatch
Funkcja useDispatch() zwraca metodę dispatch sklepu danych.
import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";
function Counter() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
Kliknięcie następnie przebiega w następujący sposób:
Button Click
↓
dispatch(increment())
↓
Action
↓
Reducer
↓
State changes
↓
useSelector detects relevant change
↓
Component re-renders
Przekazywanie danych za pomocą payload
Akcje przenoszą dane w polu payload, które pochodzi z drugiego argumentu reduktora:
incrementByAmount(state, action) {
state.value += action.payload;
}
Aplikacja przekazuje tę wartość:
dispatch(incrementByAmount(10));
co skutkuje powstaniem następującej akcji:
{
type: "counter/incrementByAmount",
payload: 10
}
Każda akcja ma type, a opcjonalnie także payload.
action
│
├── type
└── payload
Dlaczego modyfikacja stanu jest tutaj dozwolona
Klasyczne pytanie z rozmowy kwalifikacyjnej: ten reducer wydaje się modyfikować stan, co jest zabronione przez Redux.
increment(state) {
state.value += 1;
}
RTK uruchamia reducery za pośrednictwem Immer, który dostarcza projekt proxy, rejestruje zmiany i tworzy nowy obiekt niezmienialny.
Reducer
↓
Immer Proxy
↓
Track changes
↓
Create new immutable state
Otrzymujesz kod czytelny, bez naruszania zasady niezmienialności. Możesz albo zmodyfikować projekt, albo zwrócić nową wartość – nigdy obie rzeczy naraz – i nie modyfikuj w ten sposób poza reducerami RTK.
Część odpowiedzialna za koszyk zakupowy
Koszyk e-commerce zaczyna się pusty.
const initialState = {
items: [],
};
Część ta dodaje, usuwa i oczyszcza elementy; funkcja removeItem przypisuje nowy tablicę, który również jest akceptowany przez Immer.
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;
Dodawanie elementu
Mając obiekt produktu:
const product = {
id: 101,
name: "Laptop",
price: 60000,
};
Obsługa kliknięcia je wysyła:
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
a cały produkt staje się treścią przesyłaną.
{
type: "cart/addItem",
payload: {
id: 101,
name: "Laptop",
price: 60000
}
}
Następnie reducer je umieszcza na:
state.items
Usuwanie elementu
Filtracja według id:
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
Komponent przekazuje tylko id:
dispatch(removeItem(101));
zatem wewnątrz reducera:
action.payload === 101
Wyznaczanie wartości zamiast ich przechowywania
Załóżmy, że koszyk zawiera te pozycje:
[
{
price: 100,
quantity: 2,
},
{
price: 50,
quantity: 3,
}
]
Suma wynika bezpośrednio z nich:
100 × 2 + 50 × 3
= 200 + 150
= 350
Kuszące jest przechowywanie sumy obok pozycji:
{
items,
total
}
jednak wtedy każdy reducer musiałby utrzymywać je w synchronizacji. Lepiej ją obliczyć:
const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
i odczytać ją:
const total = useSelector(selectCartTotal);
Zachowaj źródło prawdy; wszystko, co da się obliczyć, wyprowadź z niego.
Selektory jako pojedynczy punkt dostępu
Selektor to funkcja, która odczytuje stan.
const selectCartItems = state =>
state.cart.items;
Komponenty używają go zamiast znania struktury stanu.
const items = useSelector(selectCartItems);
w przeciwnym razie ta ścieżka powtarza się wszędzie:
state.cart.items
Z użyciem selektora restrukturyzacja dotyka tylko jednej funkcji.
Zapisywanie w pamięci za pomocą createSelector
createSelector() przechowuje w pamięci wynik pochodny, dopóki jego dane wejściowe się nie zmienią.
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
)
);
Logika jest prosta:
Input unchanged
↓
Return cached result
Input changed
↓
Recalculate
Pomaga to przy kosztownych obliczeniach oraz w przypadku selektorów zwracających nowe tablice lub obiekty, które w przeciwnym razie zostałyby ponownie wyrenderowane przy każdej aktualizacji magazynu.
Ładowanie danych za pomocą createAsyncThunk
Aplikacje wywołują API takie jak:
GET /api/products
Zapytanie przechodzi od stanu idle przez etap loading do sukcesu
idle
↓
loading
↓
success
lub do błędu.
idle
↓
loading
↓
error
createAsyncThunk() modeluje to w ten sposób: podaje mu przedrostek typu oraz funkcję asynchroniczną; rzucenie błędem oznacza, że zapytanie nie powiodło się.
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();
}
);
Generuje trzy działania związane z cyklem życia:
fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected
Zarządzanie cyklem życia w slice
Slice produktów śledzi elementy, status oraz błędy, aktualizując je w 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 definiują własne działania slice, wraz z generowanymi funkcjami tworzącymi je.
reducers: {
addItem() {}
}
extraReducers reaguje na działania zdefiniowane w innych miejscach, takie jak te pochodzące z createAsyncThunk():
pending
fulfilled
rejected
Slice jedynie je słucha.
Rozsyłanie thunka z komponentu
Rozpocznij żądanie podczas montowania komponentu:
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
W trybie Strict Mode efekty są wykonywane dwa razy podczas rozwoju, więc należy się spodziewać dwóch żądań. Następnie wybierz stan
const {
items,
status,
error,
} = useSelector(
state => state.products
);
i wyświetl treść według statusu:
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>
);
Dla bardziej złożonego pobierania danych RTK Query może zastąpić ten wzorzec.
Middleware i configureStore
Middleware znajduje się pomiędzy funkcją dispatch() a reducerem.
dispatch(action)
↓
Middleware
↓
Reducer
↓
New State
Częste zastosowania:
- Logika asynchroniczna
- Rejestracja działań
- Analityka
- Procesy autoryzacji
- Efekty uboczne
- Przechwytywanie działań
configureStore() zastępuje ręczną konfigurację, dodając domyślny middleware (w tym thunks) oraz narzędzia Redux DevTools.
const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
});
Główne wnioski
- Slice łączy stan, reducery oraz funkcje tworzące działania dla danej funkcjonalności.
- Immer zapewnia bezpieczeństwo reduktorów typu mutation, ale tylko w ramach reduktorów RTK.
- Zachowaj minimalny stan; pozostałe elementy wyznaczaj za pomocą selektorów, stosując memoizację w przypadku kosztownych operacji.
createAsyncThunk()w połączeniu zextraReducersmodeluje sytuacje ładowania, sukcesu i niepowodzenia.- Domyślne ustawienia
configureStore()wystarczają do większości aplikacji.
Powiązane materiały
- Od skryptów DOM do interfejsu jako funkcji stanu: kluczowy model Reacta — Dlaczego React zastępuje ręczne aktualizacje DOM deklaratywnymi komponentami oraz jak JSX, props, stan, ponowne renderowanie i jednokierunkowy przepływ danych wpisują się w jeden model myślowy.
- Wysyłanie danych za pomocą RTK Query: praktyczny przewodnik po mutacjach — Dowiedz się, jak używać builder.mutation() w RTK Query do wysyłania zapytań POST, zarządzania stanami ładowania i błędów oraz tworzenia działającego komponentu formularza.