Головна / Статті

Redux Toolkit від First Slice до асинхронних даних: робоча модель мислення

Дізнайтеся, як разом працюють магазин Redux Toolkit, слайси, редуктори на основі Immer, селектори та createAsyncThunk, використовуючи лічильник, кошик покупок та список продуктів.

Видавець: REACTAPP.TOP9 хв читання2008 слів

Джерела: Redux Toolkit: A Complete Guide from Basics to Production

Коли React-додаток росте, спільний стан, такий як інформація про користувача, кошик, налаштування, фільтри та дані API, поширюється між багатьма екранами, і передача всього цього через props стає крихкою. Redux вирішує цю проблему за допомогою центрального магазину даних, а Redux Toolkit (RTK) — це рекомендований спосіб роботи з Redux сьогодні. Цей посібник проводить вас від простого сегмента для підрахунку кількості до кошика з обчисленими загальними сумами та списку, який отримує дані з API, щоб ви могли впевнено читати та писати код RTK.

Спочатку переконайтеся, чи справді вам потрібен магазин даних: сама передача даних через props не є причиною для встановлення Redux.

Основна ідея Redux

Redux зберігає спільний стан у одному магазині даних та змінює його лише через дії, якими керують редюсери, тож кожне оновлення є передбачуваним. Без нього дані передаються по одному рівню props за раз.

Component
   ↓
Props
   ↓
Component
   ↓
Props
   ↓
Component

За допомогою магазину компоненти читають те, що їм потрібно, та надсилають дії для запиту на зміни.

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

Що додає Redux Toolkit

RTK об’єднує інструменти для поширених завдань у Redux.

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

У класичному Redux одна функція розподілялась між багатьма файлами.

Action Types
Action Creators
Reducers
Constants
Store
Middleware

createSlice() дозволяє виразити більшу частину цього в одному місці.

Як дані проходять через додаток RTK

Компоненти читають дані за допомогою useSelector() та надсилають дії за допомогою useDispatch(); редуктори створюють новий стан.

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

Основний цикл:

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

Встановлення та створення магазину

Встановіть два пакети:

npm install @reduxjs/toolkit react-redux

@reduxjs/toolkit містить API Redux; react-redux під’єднує їх до React.

useSelector()
useDispatch()
Provider

Приклад додатку-лічильника організований за функціями.

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

configureStore() приймає карту редукторів слайсів; кожен ключ стає гілкою стану верхнього рівня.

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

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

Отриманий стан:

{
  counter: {
    value: 0
  }
}

Слайси: одна функція, один файл

Кожен слайс відповідає одній логічній частині стану, і у додатках зазвичай їх кілька.

authSlice
cartSlice
productSlice
userSlice
notificationSlice

Слайс лічильника визначає назву, початковий стан та редуктор для кожної операції, а потім експортує створені функції дій та редуктор.

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;

Одне викликання замінює три ручно написані фрагменти коду.

Action Types
     +
Action Creators
     +
Reducer

Що генерує createSlice

Розглянемо мінімальний слайс:

createSlice({
  name: "counter",

  initialState,

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

RTK поєднує назву слайса та ключ редуктора у тип дії

"counter/increment"

та створює функцію дії.

increment()

Отже, dispatch(increment()) еквівалентно виклику:

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

Підключення сховища до React

У файлі main.jsx компонент Provider робить сховище доступним для кожного компонента, який знаходиться нижче нього.

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>
);

Читання даних за допомогою useSelector

Функція useSelector() вибирає саме ту частину стану, яка потрібна компоненту.

import { useSelector } from "react-redux";

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

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

Компонент перерисовується лише тоді, коли змінюється це значення; див. як React-Redux вирішує перерисовувати компоненти.

Запис даних за допомогою useDispatch

Функція useDispatch() повертає метод dispatch сховища.

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

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

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

Потім процес кліку відбувається так:

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

Передача даних за допомогою payload

Дії передають дані у полі payload, яке береться з другого аргумента редуктора:

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

Викликаючий код передає значення:

dispatch(incrementByAmount(10));

що створює таку дію:

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

Кожна дія має type, а за бажанням — payload.

action
│
├── type
└── payload

Чому тут дозволяється зміна стану

Класичне запитання під час співбесіди: цей редюсер, здається, змінює стан, що заборонено Redux.

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

RTK виконує редюсери через Immer, який передає проект-проксі, фіксує зміни та створює новий незмінний об’єкт.

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

Ви отримуєте зрозумілий код без порушення принципу незмінності. Або змінюйте проект, або повертайте нове значення — ніколи обидва варіанти одночасно, і не змінюйте стан таким чином поза редюсерами RTK.

Частина для кошика покупок

Електронний кошик спочатку є порожнім.

const initialState = {
  items: [],
};

Ця частина додає, видаляє та очищує елементи; removeItem присвоює новий масив, який також приймає 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;

Додавання елемента

Якщо у нас є об’єкт продукту:

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

Обробник кліку відправляє його:

const dispatch = useDispatch();

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

і весь продукт стає вмістом даних.

{
  type: "cart/addItem",

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

Потім редюсер додає його до:

state.items

Видалення елемента

Фільтрація за ідентифікатором:

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

Компонент передає лише ідентифікатор:

dispatch(removeItem(101));

тож усередині редюсера:

action.payload === 101

Отримання значень замість їх зберігання

Припустимо, кошик містить такі записи:

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

Загальна сума визначається безпосередньо:

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

Хочеться зберігати загальну суму поруч із елементами:

{
  items,
  total
}

але тоді кожен редюсер мусить підтримувати синхронність обох. Краще обчислювати її:

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

і читати її:

const total = useSelector(selectCartTotal);

Зберігайте джерело істини; отримуйте все, що можна обчислити, з нього.

Селектори як єдиний точка доступу

Селектор — це функція, яка читає стан.

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

Компоненти використовують його замість того, щоб знати форму стану.

const items = useSelector(selectCartItems);

Інакше цей шлях буде повторюватися скрізь:

state.cart.items

За допомогою селектора реструктуризація торкається лише однієї функції.

Зберігання у пам’яті за допомогою createSelector

createSelector() зберігає отриманий результат у кеші до тих пір, поки не зміняться його вхідні дані.

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
    )
);

Логіка проста:

Input unchanged
      ↓
Return cached result

Input changed
      ↓
Recalculate

Це допомагає під час виконання ресурсомістких обчислень та у випадках, коли селектори повертають нові масиви чи об’єкти, які інакше змусили б переробити відображення після кожної зміни даних.

Завантаження даних за допомогою createAsyncThunk

Додатки викликають API на кшталт:

GET /api/products

Запит проходить стадії idle, loading та success

idle
 ↓
loading
 ↓
success

або error.

idle
 ↓
loading
 ↓
error

createAsyncThunk() моделює цей процес: потрібно вказати йому префікс типу та асинхронну функцію; кидання винятку означає, що запит зазнав невдачі.

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();
  }
);

Він генерує три дії життєвого циклу:

fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected

Обробка життєвого циклу в слисі

Слис продуктів відстежує елементи, статус та помилки, оновлюючи їх у 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 проти extraReducers

reducers визначають власні дії слису разом із генерованими функціями їх виконання.

reducers: {
  addItem() {}
}

extraReducers реагує на дії, визначені в інших місцях, наприклад, ті, що походять від createAsyncThunk():

pending
fulfilled
rejected

Слис лише прослуховує ці дії.

Надсилання thunk з компонента

Почніть запит під час монтування:

const dispatch = useDispatch();

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

У режимі Strict Mode ефекти виконуються двічі під час розробки, тому очікуйте двох запитів. Потім виберіть стан

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

та відобразіть його за статусом:

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>
);

Для більш складного отримання даних RTK Query може замінити цю схему.

Мідлвейр та configureStore

Мідлвейр розташовується між функцією dispatch() та редуктором.

dispatch(action)
      ↓
   Middleware
      ↓
   Reducer
      ↓
 New State

Поширені випадки використання:

  • Асинхронна логіка
  • Логування
  • Аналітика
  • Процеси автентифікації
  • Побічні ефекти
  • Перехоплення дій

configureStore() замінює ручна налаштування, додаючи стандартний мідлвейр (включаючи thunks) та інструменти Redux DevTools.

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

Основні висновки

  • Slice об’єднує стан, редуктори та функції створення дій для однієї функціональності.
  • Immer робить редуктори у стилі мутацій безпечними, але лише всередині редукторів RTK.
  • Зберігайте мінімальний стан; решту отримуйте за допомогою селекторів, мемоїзуючи їх у випадках високих витрат ресурсів.
  • createAsyncThunk() разом із extraReducers моделюють стани завантаження, успіху та невдачі.
  • Стандартні налаштування configureStore() підходять для більшості додатків.