Привет, Хабр!

Сегодня рассмотрим библиотеку Redux для JS, зачем она нужна, и стоит ли она вашего внимания. Redux — это библиотека для управления состоянием приложения. Redux создан для тех случаев, когда:

  • У вас огромное приложение, и нужно управлять кучей данных.

  • Эти данные нужно шарить между компонентами, которые находятся на разных уровнях иерархии.

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

Redux помогает:

  1. Упорядочить данные.

  2. Упростить их доступ из любой точки приложения.

  3. Стандартизировать логику изменения данных.

Главный принцип Redux — один источник правды. Все данные приложения хранятся в одном месте — в store. Если хочется что‑то изменить:

  1. Вы диспатчите action (описание того, что должно произойти).

  2. Данные обновляются через чистую функцию reducer (чистую — значит без побочных эффектов).

  3. Новое состояние становится доступным для всех компонентов.

Чаще всего Redux используется в связке с React, и это неудивительно — react-redux делает их совместную работу невероятно удобной. Но при этом, Redux вполне может работать с другими фреймворками (или даже без них).

Основной функционал Redux

Для начала установим Redux и его дружка — react-redux:

npm install redux react-redux

redux — это ядро библиотеки. А react-redux — это набор инструментов для интеграции Redux с React.

Создание Store

Начнем с главного — store. Это центральное хранилище состояния. Все, что вы будете хранить, находится здесь:

import { createStore } from 'redux';

// Начальное состояние
const initialState = {
  counter: 0,
};

// Редьюсер — чистая функция, которая обновляет состояние
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, counter: state.counter + 1 };
    case 'DECREMENT':
      return { ...state, counter: state.counter - 1 };
    default:
      return state;
  }
}

// Создаём store
const store = createStore(counterReducer);

console.log(store.getState()); // { counter: 0 }

Редьюсер получает текущее состояние и действие (action) и возвращает новое состояние.

Actions: говорим, что делать

Action — это просто объект с обязательным полем type. Пример:

const incrementAction = { type: 'INCREMENT' };
const decrementAction = { type: 'DECREMENT' };

Каждое действие говорит редьюсеру: «Давай что‑то сделаем».

Reducer

Вот редьюсер из нашего примера:

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, counter: state.counter + 1 };
    case 'DECREMENT':
      return { ...state, counter: state.counter - 1 };
    default:
      return state;
  }
}
  • state — текущее состояние.

  • action — что мы хотим сделать.

  • Редьюсер обязан вернуть новое состояние. Если действие не распознано, возвращаем старое.

Подключение React и Redux

Настало время объединить Redux с React. Это проще, чем кажется, благодаря react-redux:

Provider делает store доступным для всех компонентов:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import counterReducer from './reducers';

const store = createStore(counterReducer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Теперь подключаем хуки useSelector и useDispatch и любой компонент сможет получать данные из store через useSelector и отправлять действия через useDispatch:

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const counter = useSelector((state) => state.counter);
  const dispatch = useDispatch();

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
}

export default Counter;

Redux Toolkit

Ванильный Redux требует много шаблонного кода: отдельные экшены, редьюсеры, константы... Все это звучит немного утомительно. Вот почему появился Redux Toolkit. Это набор инструментов, который значительно упрощает работу и именно им все пользуются при работе с Redux на сегодняшний день.

С Redux Toolkit можно:

  1. Создавать редьюсеры и экшены в одной функции createSlice.

  2. Избавиться от ручного управления состоянием через Immer.js, встроенный в Toolkit.

  3. Упрощать настройки store через configureStore.

Пример:

import { configureStore, createSlice } from '@reduxjs/toolkit';

// Создаем slice (редьюсер + экшены)
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  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;

// Store
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
});

export default store;

Теперь вместо того, чтобы писать тонны кода для экшенов и редьюсеров, все это создается автоматом.

Подробнее про Toolkit можно глянуть здесь.

Middleware

Одно из самых мощных, но недооцененных на мой взгляд свойств Redux — это middleware. По сути, это функции, которые сидят между экшенами и редьюсерами, и могут перехватывать действия, добавлять дополнительную логику или даже модифицировать экшены во время их действия.

В Redux middleware применяются через функцию applyMiddleware:

import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';

const store = createStore(rootReducer, applyMiddleware(logger));

redux-logger выводит информацию о каждом экшене и состоянии в консоль.

Асинхронность и Thunk

Redux по дефолту синхронный, но для работы с асинхронными операциями — например, запросами к API — есть специальное middleware redux-thunk:

npm install redux-thunk

Подключаем redux-thunk:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const store = createStore(rootReducer, applyMiddleware(thunk));

А теперь создадим асинхронный экшен:

// actions/userActions.js
export const fetchUsers = () => async (dispatch) => {
  dispatch({ type: 'FETCH_USERS_REQUEST' });

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    const data = await response.json();
    dispatch({ type: 'FETCH_USERS_SUCCESS', payload: data });
  } catch (error) {
    dispatch({ type: 'FETCH_USERS_FAILURE', payload: error.message });
  }
};

Теперь Redux может обрабатывать сложные асинхронные сценарии.

Теперь перейдем к практике.

Пример применения библиотеки

Представим, что есть интернет‑магазин, специализирующийся на товарах для котиков: игрушки, корм, лежанки, лазилки и все, что может сделать пушистого клиента счастливым. Нам нужно управлять следующими состояниями приложения:

  1. Список товаров: загрузка из API и отображение на странице.

  2. Корзина: добавление и удаление товаров.

  3. Авторизация пользователя: чтобы позволить зарегистрированным пользователям оформлять заказы.

  4. Оформление заказа: управление данными для оплаты и доставки.

Чтобы сделать приложение масштабируемым и поддерживаемым, разобьем состояние на несколько модулей: products, cart, auth, order. Каждый модуль будет представлять свою «часть состояния» (state slice) и работать через Redux Toolkit.

Подготовка состояния товаров

Сначала настроим модуль для работы с товарами. Допустим, мы загружаем список товаров с сервера.

Slice для товаров:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Асинхронный thunk для загрузки товаров
export const fetchProducts = createAsyncThunk(
  'products/fetchProducts',
  async () => {
    const response = await fetch('https://api.example.com/cat-products');
    const data = await response.json();
    return data;
  }
);

const productsSlice = createSlice({
  name: 'products',
  initialState: {
    items: [],
    status: 'idle', // idle | loading | succeeded | failed
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export default productsSlice.reducer;

Теперь есть асинхронный экшен fetchProducts, который загружает товары и обновляет состояние.

Управление корзиной

В корзине нужно уметь добавлять товары, изменять их количество и удалять. Также будем хранить общее количество товаров и сумму заказа.

Slice для корзины:

import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: {
    items: [], // [{ id, name, price, quantity }]
    totalItems: 0,
    totalPrice: 0,
  },
  reducers: {
    addToCart: (state, action) => {
      const product = action.payload;
      const existingItem = state.items.find((item) => item.id === product.id);

      if (existingItem) {
        existingItem.quantity += 1;
      } else {
        state.items.push({ ...product, quantity: 1 });
      }

      state.totalItems += 1;
      state.totalPrice += product.price;
    },
    removeFromCart: (state, action) => {
      const productId = action.payload;
      const existingItem = state.items.find((item) => item.id === productId);

      if (existingItem) {
        state.totalItems -= existingItem.quantity;
        state.totalPrice -= existingItem.price * existingItem.quantity;
        state.items = state.items.filter((item) => item.id !== productId);
      }
    },
    updateQuantity: (state, action) => {
      const { id, quantity } = action.payload;
      const existingItem = state.items.find((item) => item.id === id);

      if (existingItem) {
        const quantityDiff = quantity - existingItem.quantity;
        existingItem.quantity = quantity;
        state.totalItems += quantityDiff;
        state.totalPrice += quantityDiff * existingItem.price;
      }
    },
  },
});

export const { addToCart, removeFromCart, updateQuantity } = cartSlice.actions;
export default cartSlice.reducer;

Авторизация пользователя

Авторизация нужна для оформления заказа и сохранения истории покупок. Будем хранить информацию о текущем пользователе в модуле auth.

Slice для авторизации:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const login = createAsyncThunk(
  'auth/login',
  async (credentials) => {
    const response = await fetch('https://api.example.com/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
      headers: { 'Content-Type': 'application/json' },
    });
    const data = await response.json();
    return data; // { userId, token }
  }
);

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: null,
    token: null,
    status: 'idle',
    error: null,
  },
  reducers: {
    logout: (state) => {
      state.user = null;
      state.token = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(login.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.user = action.payload.userId;
        state.token = action.payload.token;
      })
      .addCase(login.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export const { logout } = authSlice.actions;
export default authSlice.reducer;

Оформление заказа

После выбора товаров и авторизации отправляем данные заказа на сервер.

Slice для заказа:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const submitOrder = createAsyncThunk(
  'order/submitOrder',
  async (orderDetails, { getState }) => {
    const { token } = getState().auth;
    const response = await fetch('https://api.example.com/orders', {
      method: 'POST',
      body: JSON.stringify(orderDetails),
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
    });
    return await response.json();
  }
);

const orderSlice = createSlice({
  name: 'order',
  initialState: { status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(submitOrder.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(submitOrder.fulfilled, (state) => {
        state.status = 'succeeded';
      })
      .addCase(submitOrder.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export default orderSlice.reducer;

И вот так, шаг за шагом, мы построили интернет‑магазин для котиков, который может справиться с любым капризом пушистых клиентов: от корзины до оформления заказа. Redux стал связующим звеном между всеми частями нашего приложения — управление товарами, авторизация, обработка заказов — все четко и предсказуемо. А благодаря Redux Toolkit обошлись без тонны шаблонного кода.


Что в итоге

Redux — штука мощная, но не панацея. Он идеален, если у вас сложное приложение с кучей состояний, которыми нужно управлять централизованно. Дальше дело за вами: экспериментируйте, пробуйте разные подходы, но не забывайте, что Redux нужен не всегда. Иногда и Context API за глаза хватит.

Если хочется копнуть глубже:

Что ещё изучить? Разберитесь с redux-thunk и redux-saga для асинхронщины, гляньте Reselect для оптимизации селекторов. И обязательно поиграйтесь с Redux DevTools.

Делитесь своим опытом работы с Redux и советами для начинающих!

Также хочется напомнить про открытые уроки, которые пройдут в рамках набора на курс Otus "JavaScript Developer. Professional":

Комментарии (7)


  1. artptr86
    04.12.2024 00:29

    В простом приложении Redux не нужен. В сложном же приложении он неудобен.


    1. GeniyZ
      04.12.2024 00:29

      Не могли бы вы подсказать альтернативу.
      Я как раз ищу что-то удобное, и есть подозрения, что приложение разрастётся.


      1. clerik_r
        04.12.2024 00:29

        Не могли бы вы подсказать альтернативу

        MobX разумеется, в 2017 году уже это было очевидно.

        Причем это не просто альтернатива, это единственное решение из всех что есть, для связки с React'om где у вас не будет кровь из глаз идти, где вы будете писать минимально возможно кол-во кода и т.д. и т.п. И MobX нужно использовать и как глобальное состояние и как локальное(у компонента).

        Спойлер:

        interface IMyComponentProps {
          color: 'black' | 'white';
          count: number;
        }
        
        export const MyComponent = observer((props: IMyComponentProps) => {
            const [state] = useState(() => new MyComponentLocalStae(props));
            state.props = props;
            
            return <div onClick={state.incr}>Hello world ${state.counter}</div>
        });
        
        class MyComponentLocalStae {
          counter = 1;
          
          constructor() {
            makeAutoObservable(this);
          }
        
          incr = () => {
            this.counter++;
          }
        }


  1. GlennMiller1991
    04.12.2024 00:29

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


  1. tvadzim78
    04.12.2024 00:29

    npm install redux react-reduxй

    опечатка наверное


  1. kubk
    04.12.2024 00:29

    addToCart: (state, action) => {
      const product = action.payload;
      const existingItem = state.items.find((item) => item.id === product.id);
    
      if (existingItem) {
        existingItem.quantity += 1;
      } else {
        state.items.push({ ...product, quantity: 1 });
      }
    
      state.totalItems += 1;
      state.totalPrice += product.price;
    },

    1) Тут же явное дублирование информации - ручная синхронизация totalItems и totalPrice и как следствие возможные баги. Если сделать totalItems / totalPrice селекторами (или computed в терминологии Mobx), то не нужно будет при добавлении / удалении из корзины вручную синхронизировать. Must have для сложных приложений, где содержимое корзины может поменяться из 5-10 разных мест.

    2) Классно что благодаря Immer код стал более читабельный. Это позволяет писать

    quizz.questions[0].answers.push(newAnswer)

    вместо

    const newState = {
      ...quizz,
      questions: quizz.questions.map((question, index) =>
        index === 0
          ? {
              ...question,
              answers: [...question.answers, newAnswer],
            }
          : question
      ),
    };

    Другое дело, что на Mobx люди так пишут лет 8 уже. Может проще сразу на Mobx?

    Redux идеален, если у вас сложное приложение с кучей состояний

    3) Как раз таки в сложном большом приложении Redux создаёт проблемы. Представьте админку на 100 страниц. Централизованный стор означает, что код бизнес-логики для всех страниц инициализируется сразу. Из коробки нет возможности сделать ленивое создание сторов под страницы. Я бывал на проектах, где разработчики пытались изворачиваться и делали Redux стор с динамическими ключами, чтобы не держать 100 ключей в глобальном сторе. Работало это плохо, и были проблемы с типизацией. У модульных сторов этой проблемы нет.





  1. clerik_r
    04.12.2024 00:29

    Redux идеален, если у вас сложное приложение с кучей состояний

    Как бы всё ровно наоборот, вам так не кажется? Зачем людей в заблуждение вводить?

    Есть такая штука, с которой у вас вообще не будет проблем от слова со всем, как в громадных приложениях, так и в мелких, начинается на M, заканчивается на X .Есть догадки?)