Привет, Хабр!
Сегодня рассмотрим библиотеку Redux для JS, зачем она нужна, и стоит ли она вашего внимания. Redux — это библиотека для управления состоянием приложения. Redux создан для тех случаев, когда:
У вас огромное приложение, и нужно управлять кучей данных.
Эти данные нужно шарить между компонентами, которые находятся на разных уровнях иерархии.
Есть сложная логика обновления данных, и хочется, чтобы код этой логики был не просто работающим, но и понятным через полгода.
Redux помогает:
Упорядочить данные.
Упростить их доступ из любой точки приложения.
Стандартизировать логику изменения данных.
Главный принцип Redux — один источник правды. Все данные приложения хранятся в одном месте — в store
. Если хочется что‑то изменить:
Вы диспатчите action (описание того, что должно произойти).
Данные обновляются через чистую функцию reducer (чистую — значит без побочных эффектов).
Новое состояние становится доступным для всех компонентов.
Чаще всего 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 можно:
Создавать редьюсеры и экшены в одной функции
createSlice
.Избавиться от ручного управления состоянием через Immer.js, встроенный в Toolkit.
Упрощать настройки
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 может обрабатывать сложные асинхронные сценарии.
Теперь перейдем к практике.
Пример применения библиотеки
Представим, что есть интернет‑магазин, специализирующийся на товарах для котиков: игрушки, корм, лежанки, лазилки и все, что может сделать пушистого клиента счастливым. Нам нужно управлять следующими состояниями приложения:
Список товаров: загрузка из API и отображение на странице.
Корзина: добавление и удаление товаров.
Авторизация пользователя: чтобы позволить зарегистрированным пользователям оформлять заказы.
Оформление заказа: управление данными для оплаты и доставки.
Чтобы сделать приложение масштабируемым и поддерживаемым, разобьем состояние на несколько модулей: 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
за глаза хватит.
Если хочется копнуть глубже:
FAQ Redux: https://redux.js.org/faq
Redux Toolkit: https://redux‑toolkit.js.org/
React‑Redux: https://react‑redux.js.org/
Что ещё изучить? Разберитесь с redux-thunk
и redux-saga
для асинхронщины, гляньте Reselect
для оптимизации селекторов. И обязательно поиграйтесь с Redux DevTools.
Делитесь своим опытом работы с Redux и советами для начинающих!
Также хочется напомнить про открытые уроки, которые пройдут в рамках набора на курс Otus "JavaScript Developer. Professional":
4 декабря: Создание веб-компонентов и использование Shadow DOM. Узнать подробнее
18 декабря: Управление состоянием с Pinia для Vue3. Узнать подробнее
Комментарии (7)
GlennMiller1991
04.12.2024 00:29Спасибо за статью. Вы не подскажите, если мне нужно поднять два экземпляра сложной логики, стейт которой хранится в редаксе, то как я могу это сделать?
kubk
04.12.2024 00:29addToCart: (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 ключей в глобальном сторе. Работало это плохо, и были проблемы с типизацией. У модульных сторов этой проблемы нет.
clerik_r
04.12.2024 00:29Redux идеален, если у вас сложное приложение с кучей состояний
Как бы всё ровно наоборот, вам так не кажется? Зачем людей в заблуждение вводить?
Есть такая штука, с которой у вас вообще не будет проблем от слова со всем, как в громадных приложениях, так и в мелких, начинается на M, заканчивается на X .Есть догадки?)
artptr86
В простом приложении Redux не нужен. В сложном же приложении он неудобен.
GeniyZ
Не могли бы вы подсказать альтернативу.
Я как раз ищу что-то удобное, и есть подозрения, что приложение разрастётся.
clerik_r
MobX разумеется, в 2017 году уже это было очевидно.
Причем это не просто альтернатива, это единственное решение из всех что есть, для связки с React'om где у вас не будет кровь из глаз идти, где вы будете писать минимально возможно кол-во кода и т.д. и т.п. И MobX нужно использовать и как глобальное состояние и как локальное(у компонента).
Спойлер: