Раздел 7 · Браузер и Frontend

Redux Toolkit: журнал, slice и нормализация

middle~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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.