Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Редьюсеры, Context и внешние хранилища
reducers_context

Редьюсеры, Context и внешние хранилища

useReducer, разделение состояния и команд, Context, useSyncExternalStore и границы глобального состояния.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Редьюсеры, Context и внешние хранилища

Глобальное состояние не является целью: задача — сделать переходы явными и ограничить область подписки.

#Результат урока

Вы примените редьюсер для сложных переходов, безопасно передадите зависимости через Context и подключите внешнее хранилище без рассогласованных снимков.

Понадобятся владение состоянием, неизменяемые обновления и размеченные объединения. Итог урока — типизированная корзина с редьюсером, раздельными контекстами и внешним хранилищем состояния сети.

#1. Редьюсер как чистая таблица переходов

Редьюсер получает состояние и действие, а возвращает следующее состояние. Он не отправляет запросы, не читает время и не изменяет входные данные. Чистота позволяет отдельно проверить переходы и повторить действие. Тип действия — размеченное объединение с минимальным набором данных.

// src/cartReducer.ts type Action = | { type: 'added'; item: Item } | { type: 'removed'; id: string }; function reducer(state: State, action: Action): State { switch (action.type) { case 'added': return { ...state, items: [...state.items, action.item] }; case 'removed': return { ...state, items: state.items.filter(x => x.id !== action.id) }; } }

Действие описывает событие, а редьюсер — единственное место изменения этой модели.

Чистота даёт тест без отрисовки и без асинхронности:

// src/cartReducer.test.ts it('удаление не затрагивает остальные позиции', () => { const before = { items: [{ id: 'a' }, { id: 'b' }] }; const after = reducer(before, { type: 'removed', id: 'a' }); expect(after.items).toEqual([{ id: 'b' }]); expect(before.items).toHaveLength(2); // вход не изменён });

Запрос внутри редьюсера ломает обе гарантии сразу: переход становится непроверяемым и неповторяемым, а StrictMode вызовет его дважды — то есть отправит два запроса.

Проверьте себя. Проверьте каждый переход без отрисовки компонента.

Частая ошибка. Вызывать fetch внутри редьюсера при действии submitted.

#2. Действие описывает событие, а не прямую запись

Названия действий вроде itemAdded объясняют произошедшее; setState переносит знание структуры к каждому месту вызова. Действие содержит необходимые данные, но не событие DOM. Такой журнал облегчает диагностику и смену внутренней структуры состояния.

Сравните два набора действий для одного сценария оформления заказа:

// ❌ структура состояния известна каждому вызывающему dispatch({ type: 'setField', field: 'couponCode', value: raw }); dispatch({ type: 'setField', field: 'discount', value: computeDiscount(raw) }); // ✅ одно событие, вычисление внутри перехода dispatch({ type: 'couponApplied', code: raw });

Второй вариант нельзя выполнить наполовину: скидка и код меняются одним переходом. Событие DOM в действии — отдельная проблема:

// ❌ редьюсер получает MouseEvent: не сериализуется, не воспроизводится в тесте dispatch({ type: 'quantityChanged', event }); // ✅ только данные предметной области dispatch({ type: 'quantityChanged', id: item.id, quantity: Number(event.target.value) });

Действие, состоящее только из данных, можно записать в журнал, воспроизвести в тесте и отправить в телеметрию — с объектом события ничего этого сделать нельзя.

Проверьте себя. Замените общий setField предметными действиями оформления заказа.

Частая ошибка. Передавать редьюсеру MouseEvent и читать target внутри перехода.

#3. Инициализация и сброс редьюсера

Третий аргумент useReducer задаёт ленивую инициализацию. Новые свойства не переинициализируют редьюсер автоматически. Сброс можно выразить действием с исходными данными или новым экземпляром компонента через key. Выбор зависит от смысла предметной области.

Функция создания начального состояния гарантирует, что каждый сброс получит свежий объект:

// src/features/cart/state.ts export function createInitialCart(): CartState { return { items: [], coupon: null, status: 'editing' }; // новый объект на каждый вызов } const [state, dispatch] = useReducer(reducer, undefined, createInitialCart); // сброс как предметное событие case 'orderConfirmed': return createInitialCart();

Общий изменяемый объект даёт скрытую связь между сбросами:

// ❌ initialCart один на весь модуль: мутация в одном переходе переживёт сброс const initialCart = { items: [], coupon: null, status: 'editing' }; case 'orderConfirmed': return initialCart; // вернёт объект, который уже мог быть изменён

Выбор между действием сброса и новым key зависит от смысла: подтверждённый заказ — это предметное событие корзины, поэтому уместнее действие; смена редактируемой записи — другая сущность, там уместнее key.

Проверьте себя. Реализуйте сброс корзины после подтверждённого заказа, не читая изменяемый исходный объект.

Частая ошибка. Изменять объект initialState и использовать его для каждого сброса.

#4. Context как доставка зависимости

Context избавляет от передачи свойства через промежуточные уровни, но создаёт неявную зависимость потребителя от провайдера. Значение должно быть узким, а провайдер — находиться близко к владельцу. Значение по умолчанию не сработает, если провайдер передал undefined; пользовательский хук обычно сообщает понятную ошибку при отсутствии провайдера.

Хук-обёртка превращает загадочную ошибку undefined в понятное сообщение:

// src/features/checkout/context.ts const CheckoutContext = createContext<CheckoutValue | null>(null); export function useCheckout(): CheckoutValue { const value = useContext(CheckoutContext); if (value === null) { throw new Error('useCheckout вызван вне CheckoutProvider'); } return value; // тип сузился: дальше не нужны проверки на null }

Без такой проверки первая ошибка будет выглядеть как Cannot read properties of null в случайном месте дерева. Отдельно стоит следить за размером значения: контекст, содержащий авторизацию, тему, маршрутизатор, клиент API и всё состояние приложения, делает каждого потребителя зависимым от всего — и заново отрисовывает всё дерево при любом изменении.

Проверьте себя. Создайте useCheckout с явной ошибкой при использовании вне CheckoutProvider.

Частая ошибка. Сложить авторизацию, тему, маршрутизатор, API и всё состояние приложения в один объект контекста.

#5. Раздельные контексты состояния и команд

Функция dispatch из useReducer имеет стабильную ссылку. Если состояние и команды лежат в одном новом объекте, каждое обновление меняет значение для всех потребителей. Раздельные контексты позволяют компонентам, которым нужны только команды, не подписываться на состояние. Для частых больших обновлений могут понадобиться селекторы или внешнее хранилище.

Один объект на состояние и команды делает всех потребителей подписчиками состояния:

// ❌ новый объект на каждое обновление: кнопка «Очистить» отрисовывается всегда <CartContext.Provider value={{ state, dispatch }}> // ✅ два контекста: значение команд стабильно между обновлениями <CartStateContext.Provider value={state}> <CartDispatchContext.Provider value={dispatch}> {children} </CartDispatchContext.Provider> </CartStateContext.Provider>

Теперь компонент, которому нужны только команды, читает стабильное значение и не перерисовывается при изменении корзины:

function ClearCartButton() { const dispatch = use(CartDispatchContext); // не подписан на state return <button onClick={() => dispatch({ type: 'cleared' })}>Очистить</button>; }

Мемоизация одного большого объекта такой эффект не даёт: useMemo пересчитает значение при каждом изменении состояния, потому что состояние входит в зависимости.

Проверьте себя. Измерьте отрисовки кнопки, использующей только dispatch, до и после разделения контекстов.

Частая ошибка. Мемоизировать огромный объект контекста, не измерив потребителей и частоту изменений.

#6. Селекторы и производные данные

Селектор — чистая функция State -> Value. Он централизует знание структуры и упрощает тесты. Мемоизация нужна только для дорогого вычисления или стабильной ссылки при подписке; селектор не отправляет запросы. Библиотеки внешних хранилищ могут подписывать по селектору, обычный Context этого автоматически не делает.

Производные значения выводятся из позиций, а не хранятся рядом с ними:

// src/features/cart/selectors.ts export const selectSubtotal = (state: CartState): number => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0); export const selectDiscount = (state: CartState): number => state.coupon === null ? 0 : Math.round(selectSubtotal(state) * state.coupon.rate); export const selectTotal = (state: CartState): number => Math.max(0, selectSubtotal(state) - selectDiscount(state));

Хранение суммы отдельным полем требует пересчёта в каждой ветви редьюсера — и первая забытая ветвь даёт корзину, где итог не совпадает с позициями. Селекторы же тестируются без компонентов, включая граничные случаи: пустая корзина, скидка больше суммы, нулевое количество.

Проверьте себя. Выделите селекторы суммы, скидки и итога и проверьте граничные случаи.

Частая ошибка. Хранить сумму и итог отдельно от позиций и обновлять в каждой ветви редьюсера.

#7. useSyncExternalStore и контракт снимка

Внешнее хранилище подключается через subscribe и getSnapshot; для SSR добавляется getServerSnapshot. Пока хранилище не изменилось, повторное чтение снимка должно возвращать то же значение по Object.is. React координирует чтения, чтобы разные части интерфейса не увидели разные версии данных.

// src/NetworkStatus.tsx const online = useSyncExternalStore( networkStore.subscribe, networkStore.getSnapshot, networkStore.getServerSnapshot, );

Не создавайте новый объект внутри getSnapshot без кеша: React сочтёт хранилище изменившимся при каждом чтении.

Правильное хранилище кеширует снимок и обновляет его только при настоящем изменении:

// src/shared/store/networkStore.ts let snapshot = { online: navigator.onLine }; const listeners = new Set<() => void>(); function handleChange() { if (snapshot.online === navigator.onLine) return; // ничего не изменилось snapshot = { online: navigator.onLine }; // новая ссылка только при изменении listeners.forEach((listener) => listener()); } export const networkStore = { subscribe(listener: () => void) { if (listeners.size === 0) { window.addEventListener('online', handleChange); window.addEventListener('offline', handleChange); } listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) { window.removeEventListener('online', handleChange); window.removeEventListener('offline', handleChange); } }; }, getSnapshot: () => snapshot, // та же ссылка между изменениями getServerSnapshot: () => ({ online: true }), };

Вариант getSnapshot: () => ({ online: navigator.onLine }) возвращает новый объект при каждом чтении: React видит бесконечное изменение хранилища и уходит в цикл отрисовок.

Проверьте себя. Сделайте хранилище состояния сети и проверьте отписку после размонтирования.

Частая ошибка. getSnapshot: () => ({ online: navigator.onLine }) с новым object всегда.

#8. Выбор локального состояния, Context или внешнего хранилища

Начинайте с локального состояния у владельца. Context нужен для относительно стабильной зависимости в поддереве. Внешнее хранилище оправдано множеством независимых подписчиков, частыми обновлениями, селекторами, данными вне React или жизнью между страницами. Серверный кеш не нужно автоматически копировать в клиентское хранилище.

Классификация занимает минуту и снимает большинство споров о «глобальном состоянии»:

ДанныеМеханизмПричина
Черновик полялокальное состояниеодин владелец, живёт до отправки
ТемаContextстабильное значение, нужно всему поддереву
СеансContext над результатом серверной проверкименяется редко, читается часто
Серверный списоккеш запросовудалённый владелец, нужна свежесть
Поток WebSocketвнешнее хранилищеданные вне React, частые обновления, много подписчиков

Каждое нажатие клавиши в глобальном хранилище — это обновление, на которое подписано всё дерево: интерфейс начинает «тормозить» при вводе, и никакая мемоизация этого не исправит, потому что причина в области подписки, а не в стоимости отрисовки.

Проверьте себя. Классифицируйте состояние приложения: черновик поля, тема, сеанс, серверный список, данные WebSocket.

Частая ошибка. Помещать каждое нажатие в поле ввода в глобальное хранилище.

#Лаборатория

Соберите модель состояния оформления заказа. Редьюсер обрабатывает добавление, удаление, количество, купон и результат отправки; Context разделяет состояние и команды, а внешнее хранилище сообщает о доступности сети через useSyncExternalStore. Добавьте тесты селекторов.

Работа готова, когда редьюсер чист и исчерпывающ, потребители не перерисовываются из-за неиспользуемого контекста команд, а внешний снимок стабилен.

#Источники для сверки

  • React useReducer
  • React useContext
  • React useSyncExternalStore
  • React: Scaling Up with Reducer and Context

Устранение неисправностей

Далее: Эффекты, ссылки и внешние системы