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

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/863002/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *