Redux Toolkit: журнал, slice и нормализация
К этому уроку у тебя на руках карта состояния Echo из урока 09.
Ты знаешь, что route state живёт в URL, server state просит кэш, а форма держит
своё. Осталось самое шумное: UI state, который делят несвязанные компоненты.
Тема, активный канал, открыт ли сайдбар, кто залогинен. Сейчас он размазан по
useState и AuthContext, и каждый, кому он нужен, тянет его пропсами через
полдерева. Пора дать ему один дом с понятными правилами входа и выхода. Этот дом
называется Redux, а точнее Redux Toolkit, стандартный способ готовить его в 2026.
1. Журнал диспетчера
Представь диспетчерскую большого склада. Никто не лезет на полки руками. Чтобы
что-то изменилось, ты подаёшь заявку: принять груз номер 7, отгрузить со стеллажа B. Диспетчер пишет заявку в журнал и только потом двигает товар. Через
неделю можно открыть журнал и точно сказать, кто, когда и что менял. Это и есть
Redux: компонент не трогает состояние напрямую, он подаёт заявку (action), а
изменение записывается в один журнал.
Три понятия, на которых всё держится:
- action
это объект
{ type, payload }: запись в журнале о том, что случилось; - reducer
это чистая функция
(state, action) => newState: правило, как заявка меняет состояние; - store это единый журнал плюс текущее состояние: единственный источник правды.
Почему это всё ещё нужно в 2026, когда есть useState, контекст и десяток
модных стейт-менеджеров? Не всегда нужно. Локальный тоггл прекрасно живёт в
useState, а серверные данные мы отдадим в кэш RTK Query (урок 11). Redux
выигрывает на другом: когда состояние делят много несвязанных мест и важна
предсказуемость. Явный лог действий, time-travel в devtools, тривиальное
тестирование чистых редьюсеров, единый паттерн на большую команду. За это и
держат Redux в крупных приложениях.
Раньше у Redux была заслуженная репутация многословного: на один action три
файла с константами, тайп-юнионами и switch. Это лечит
Redux Toolkit:
тот же Redux, но без ручного шаблона. Дальше весь код только на нём.
Что взять с собой
- Компонент не меняет состояние руками, он подаёт
action, заявку в журнал. reducerэто чистая функция(state, action) => newState, без сайд-эффектов.- Redux берут ради предсказуемости: явный лог, devtools, тестируемость на больших приложениях. Для локального и серверного состояния есть более простые дома.
2. Поток данных: туда и обратно
У Redux строго однонаправленный поток. Заявка всегда идёт по одному кругу:
компонент зовёт dispatch(action), action проходит цепочку middleware, попадает
в reducer, reducer возвращает новое состояние, store его сохраняет, селектор
достаёт нужный срез, и подписанный компонент перерисовывается. Назад по этому
кругу ничего не течёт. Один раз увидев этот круг, ты будешь узнавать его в любом
Redux-приложении.
Прогони заявку по кругу руками. Выбери действие, нажми его кнопку и шагай по этапам, следи, где меняется состояние и когда перерисовывается компонент.
Обрати внимание на две вещи в виджете. Состояние меняется ровно на этапе store,
ни секундой раньше: reducer лишь вернул новый объект, а подменил старый сам
store. И второе: компонент перерисовывается в самом конце, когда селектор отдал
ему изменившийся срез. Весь путь односторонний и наблюдаемый, поэтому Redux так
дружит с devtools: каждый виток круга это строка в журнале, по которой можно
прыгать вперёд и назад.
Что взять с собой
- Поток однонаправленный: dispatch, middleware, reducer, store, selector, компонент.
- Состояние заменяется на этапе store, а не в reducer: reducer только вычисляет.
- Каждый виток это запись в журнале, отсюда time-travel в devtools.
3. configureStore и createSlice
Соберём store для Echo. configureStore принимает объект reducer, где ключ это
имя среза состояния, а значение это его reducer. Devtools и middleware (включая
thunk) подключаются сами:
// apps/echo-react-rtk/src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/auth';
import channelsReducer from './slices/channels';
import messagesReducer from './slices/messages';
import uiReducer from './slices/ui';
export const store = configureStore({
reducer: {
ui: uiReducer,
auth: authReducer,
channels: channelsReducer,
messages: messagesReducer,
},
});
// типы выводим из самого store, не пишем руками
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RootState и AppDispatch выведены из store, поэтому они не разъедутся с ним
при добавлении срезов. Сам срез описывает
createSlice.
Самое неожиданное в нём: внутри reducer можно писать мутацию.
// apps/echo-react-rtk/src/store/slices/ui.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type UiState = {
activeChannel: string;
theme: 'light' | 'dark';
sidebarOpen: boolean;
};
const initialState: UiState = {
activeChannel: 'general',
theme: 'light',
sidebarOpen: true,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
channelSelected(state, action: PayloadAction<string>) {
state.activeChannel = action.payload; // выглядит как мутация
},
themeToggled(state) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
sidebarToggled(state) {
state.sidebarOpen = !state.sidebarOpen;
},
},
});
export const { channelSelected, themeToggled, sidebarToggled } = uiSlice.actions;
export default uiSlice.reducer;
Строка state.activeChannel = action.payload выглядит как нарушение всех правил:
reducer обязан быть чистым и не мутировать аргумент. Секрет в
immer:
RTK оборачивает каждый reducer, и state внутри это не настоящее состояние, а
черновик. Immer следит за изменениями черновика и собирает из них новый
иммутабельный объект, не трогая старый. Ты пишешь как мутацию, получаешь
иммутабельность. Глубокого спреда вложенных полей больше нет.
createSlice заодно сам сгенерировал action-creators. channelSelected('random')
вернёт { type: 'ui/channelSelected', payload: 'random' }, ровно то, что мы
гоняли по кругу в виджете. Имена ты не придумываешь и не дублируешь в константах.
Что взять с собой
configureStore({ reducer: { ... } })собирает store, devtools и thunk идут даром.RootStateиAppDispatchвыводи из store черезReturnType, не пиши руками.- В
createSliceпиши reducer как мутацию: immer вернёт новый иммутабельный объект. - Либо мутируй черновик, либо
returnновое значение, но не то и другое сразу.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.