Налаштування 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
Feature-based структура: кожна доменна область — окрема директорія з slice, селекторами та API.
// 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);
};
Строки реалізації
- Тиждень 1: налаштування store, feature-slices для основних доменів, типізація
- Тиждень 2: RTK Query endpoints, інтеграція з компонентами, оптимістичні оновлення
- Тиждень 3: middleware (error handling, analytics), мемоізовані селектори
- Тиждень 4: unit-тести reducers та selectors, документація соглашень по іменуванню actions







