Главная / Статьи

Redux Toolkit от первоначальной версии до обработки асинхронных данных: рабочая модель понимания

Узнайте, как хранилище Redux Toolkit, его сегменты, редьюсеры на основе Immer, селекторы и createAsyncThunk взаимодействуют друг с другом, на примере счётчика, корзины покупок и списка товаров.

Издатель: REACTAPP.TOP9 мин чтения2008 слов

Источники: Redux Toolkit: A Complete Guide from Basics to Production

По мере роста React-приложения общее состояние, такое как информация о пользователе, корзина покупок, предпочтения, фильтры и данные API, распространяется по многим экранам, и передача всего этого через пропсы становится нестабильной. Redux решает эту проблему с помощью центрального хранилища, а Redux Toolkit (RTK) сегодня считается рекомендуемым способом работы с Redux. В этом руководстве мы пройдем путь от простого счетчика до корзины с вычисляемыми итогами и списка, получаемого из API, чтобы вы могли уверенно читать и писать код на RTK.

Сначала убедитесь, действительно ли вам нужно хранилище: одни только пропсы — это не повод для установки Redux.

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

Redux хранит общее состояние в одном хранилище и изменяет его только с помощью действий, обрабатываемых редюсерами, поэтому каждое обновление предсказуемо. Без него данные передаются по слоям пропсов по одному.

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
  }
}

Slices: одна функция — один файл

Kаждый slice отвечает за одну логическую часть состояния, и в приложениях обычно их несколько.

authSlice
cartSlice
productSlice
userSlice
notificationSlice

Slice счётчика задаёт имя, начальное значение состояния и по одному редюсеру на каждую операцию, затем экспортирует созданные функции генерации действий и редюсеры.

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

Возьмём минимальный пример slice:

createSlice({
  name: "counter",

  initialState,

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

RTK объединяет имя slice и ключ редюсера в тип действия

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

В режиме строгой проверки эффекты выполняются дважды во время разработки, поэтому ожидайте двух запросов. Затем выберите нужное состояние

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() подходят для большинства приложений.