Redux для новичков: база, с которой можно стартовать

de9f7f7133ceada0c90ed9e25522ee68.png

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

Сегодня рассмотрим библиотеку 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(
  
    
  ,
  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 (
    

Counter: {counter}

); } 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»:

© Habrahabr.ru