Настройка State Management (Redux Toolkit) для React-приложения
Redux Toolkit (RTK) — официальная библиотека для написания Redux-логики. Она устраняет классические претензии к Redux: избыточный boilerplate, ручная настройка Immer, необходимость писать action creators вручную. RTK делает Redux компактным без потери предсказуемости.
RTK ≠ альтернатива Redux. RTK — это правильный способ писать Redux в 2024 году.
Чем RTK отличается от «голого» Redux
| Аспект | Redux (без RTK) | Redux Toolkit |
|---|---|---|
| Создание actions | { type: 'cart/ADD_ITEM', payload } вручную |
cartSlice.actions.addItem(payload) |
| Иммутабельность | Вручную (spread operator) | Через Immer — мутации в reducers допустимы |
| Thunk | redux-thunk отдельно |
createAsyncThunk встроен |
| Селекторы | reselect отдельно |
createSelector встроен |
| Серверные данные | Самописный код | RTK Query встроен |
| Конфигурация store | 20+ строк boilerplate | configureStore из одного вызова |
Современная архитектура с RTK
src/
store/
index.ts
hooks.ts
middleware/
errorMiddleware.ts
analyticsMiddleware.ts
features/
auth/
authSlice.ts
authSelectors.ts
authThunks.ts
products/
productsSlice.ts
productsApi.ts # RTK Query
ui/
uiSlice.ts
Feature-based структура: каждая доменная область — отдельная директория с slice, селекторами и API.
createSlice с Immer
// features/auth/authSlice.ts
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
email: string;
role: 'admin' | 'manager' | 'viewer';
permissions: string[];
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'authenticated' | 'error';
error: string | null;
}
// createAsyncThunk генерирует pending/fulfilled/rejected actions автоматически
export const login = createAsyncThunk(
'auth/login',
async (credentials: { email: string; password: string }, { rejectWithValue }) => {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!res.ok) {
const err = await res.json();
return rejectWithValue(err.message);
}
return res.json() as Promise<{ user: User; token: string }>;
} catch {
return rejectWithValue('Ошибка сети');
}
}
);
export const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
token: localStorage.getItem('token'),
status: 'idle',
error: null,
} satisfies AuthState,
reducers: {
// Immer позволяет мутировать state напрямую — под капотом всё иммутабельно
logout(state) {
state.user = null;
state.token = null;
state.status = 'idle';
localStorage.removeItem('token');
},
updateProfile(state, action: PayloadAction<Partial<User>>) {
if (state.user) {
Object.assign(state.user, action.payload);
}
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.status = 'authenticated';
localStorage.setItem('token', action.payload.token);
})
.addCase(login.rejected, (state, action) => {
state.status = 'error';
state.error = action.payload as string;
});
},
});
export const { logout, updateProfile } = authSlice.actions;
RTK Query — API-слой без boilerplate
// features/products/productsApi.ts
import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
import type { RootState } from '@/store';
const baseQuery = fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
});
// Автоматический retry с exponential backoff
const baseQueryWithRetry = retry(baseQuery, { maxRetries: 2 });
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: baseQueryWithRetry,
tagTypes: ['Product', 'Category'],
endpoints: (builder) => ({
listProducts: builder.query<PaginatedResponse<Product>, ProductQuery>({
query: (params) => ({ url: '/products', params }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Product' as const, id })), 'Product']
: ['Product'],
// Трансформация ответа для нормализации
transformResponse: (raw: ApiResponse<Product[]>) => ({
items: raw.data,
total: raw.meta.total,
page: raw.meta.page,
}),
}),
createProduct: builder.mutation<Product, CreateProductDto>({
query: (body) => ({ url: '/products', method: 'POST', body }),
invalidatesTags: ['Product'],
// Оптимистичное обновление
async onQueryStarted(body, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
productsApi.util.updateQueryData('listProducts', {}, (draft) => {
draft.items.unshift({ id: 'temp', ...body, createdAt: new Date().toISOString() });
draft.total += 1;
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});
export const { useListProductsQuery, useCreateProductMutation } = productsApi;
Middleware для сквозных задач
// store/middleware/errorMiddleware.ts
import type { Middleware } from '@reduxjs/toolkit';
import { isRejectedWithValue } from '@reduxjs/toolkit';
import { toast } from 'sonner';
export const errorMiddleware: Middleware = () => (next) => (action) => {
if (isRejectedWithValue(action)) {
const message = (action.payload as any)?.message ?? 'Неизвестная ошибка';
toast.error(message);
}
return next(action);
};
// store/middleware/analyticsMiddleware.ts
import type { Middleware } from '@reduxjs/toolkit';
const TRACKED_ACTIONS = ['cart/addItem', 'auth/login/fulfilled', 'order/submit/fulfilled'];
export const analyticsMiddleware: Middleware = () => (next) => (action) => {
if (typeof action.type === 'string' && TRACKED_ACTIONS.includes(action.type)) {
window.gtag?.('event', action.type.replace(/\//g, '_'), {
payload: JSON.stringify(action.payload),
});
}
return next(action);
};
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
export const store = configureStore({
reducer: {
auth: authSlice.reducer,
cart: cartSlice.reducer,
ui: uiSlice.reducer,
[productsApi.reducerPath]: productsApi.reducer,
},
middleware: (getDefault) =>
getDefault()
.concat(productsApi.middleware)
.concat(errorMiddleware)
.concat(analyticsMiddleware),
});
// Автоматический refetch при reconnect и focus
setupListeners(store.dispatch);
Тестирование
// features/cart/cartSlice.test.ts
import { cartSlice, addItem, removeItem, selectCartTotal } from './cartSlice';
const { reducer } = cartSlice;
describe('cartSlice', () => {
it('добавляет новый товар', () => {
const state = reducer(undefined, addItem({ id: '1', name: 'Test', price: 100, image: '' }));
expect(state.items).toHaveLength(1);
expect(state.items[0].qty).toBe(1);
});
it('увеличивает qty для существующего товара', () => {
const item = { id: '1', name: 'Test', price: 100, image: '' };
const state = [addItem(item), addItem(item)].reduce(reducer, undefined);
expect(state.items[0].qty).toBe(2);
});
it('корректно считает итог с купоном', () => {
const rootState = { cart: { items: [{ id: '1', price: 200, qty: 2 }], couponDiscount: 10 } };
expect(selectCartTotal(rootState as any)).toBe(360); // 400 - 10%
});
});
Reducers — чистые функции, тестируются без React, без рендера, без моков.
Сроки реализации
- Неделя 1: настройка store, feature-slices для основных доменов, типизация
- Неделя 2: RTK Query endpoints, интеграция с компонентами, оптимистичные обновления
- Неделя 3: middleware (error handling, analytics), мемоизированные селекторы
- Неделя 4: unit-тесты reducers и selectors, документация соглашений по именованию actions







