Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP9 min czytania2008 słó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 z extraReducers modeluje sytuacje ładowania, sukcesu i niepowodzenia.
  • Domyślne ustawienia configureStore() wystarczają do większości aplikacji.

Powiązane materiały

  • Gdzie mają znaleźć się funkcje autoryzacji, ładowania danych i obsługi błędów w React App: dziesięć rozwiązań — Dziesięć powszechnych błędów w obsłudze autoryzacji, ładowania danych, błędów oraz efektów ubocznych stanu w React oraz sposoby, w jakie intercepcje, ładowarki, cache zapytań i granice błędów je naprawiają.
  • Intercepcje Axios jako HTTP middleware: model myślowy dla aplikacji React — Zrozumienie sposobu działania intercepcji żądań i odpowiedzi Axios jako middleware, co powinno znajdować się w każdej z nich oraz jak one centralizują nagłówki autoryzacyjne i obsługę błędów 401 w React.
  • Ułożenie stanu w aplikacji strimującej w React: Context, Redux Toolkit i RTK Query — Prześledź frontend aplikacji do streamowania wideo, od propagacji wartości przez propy po zagnieżdżone dostawcy, aż po fragmenty Redux Toolkit i RTK Query, i dowiedz się, jaki rodzaj stanu pasuje do której narzędzia.