open atlas
↑ К треку
Паттерны React RXP · 06 · 03

Контекст с редьюсером

Контекст + useReducer — встроенный стор для умеренного сквозного состояния с нетривиальными переходами; раздели state и dispatch на два контекста, чтобы стабильный dispatch не перерисовывал потребителей; не годится для серверного или высокочастотного состояния.

RXP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

У тебя есть общее состояние, которого касается не пара, а несколько далёких друг от друга компонентов — корзина, залогиненный пользователь, тема с нетривиальными правилами, — и его обновления не одиночные вызовы setState, а настоящие переходы: «добавь товар, но если он уже есть — увеличь количество»; «залогинься», «выйди», «токен обновлён». Обычный useState в контексте берёт на себя чтение, но логика переходов размазывается по каждому компоненту, который её мутирует. Тащить сюда сторонний стор не хочется — оно не настолько большое.

Встроенный ответ — контекст + useReducer: редьюсер владеет каждым переходом в одном месте, а контекст доставляет state и dispatch по дереву. Неочевидная часть — та, что отличает junior-проводку от senior, — это как ты раздаёшь эти две вещи, потому что наивная раздача перерисовывает куда больше, чем должна.

Цель

После этого урока ты можешь построить сквозное состояние (корзину или поток авторизации) как редьюсер, выставленный через контекст; объяснить, почему state и dispatch кладутся в два отдельных контекста; объяснить, почему контекст dispatch никогда не вызывает перерисовок (идентичность dispatch стабильна между рендерами); обернуть оба в один компонент-провайдер с типизированными хуками; и назвать режим отказа паттерна — когда к нему тянутся, а данные на самом деле серверное состояние (используй query-библиотеку) или огромны / высокочастотны (используй внешний стор с селекторами).

1

Редьюсер кладёт каждый переход в одно место — так что форма состояния и его правила живут вместе, а не разбросаны по потребителям. С useState каждый компонент, мутирующий корзину, заново реализует «есть ли этот товар? тогда увеличить, иначе добавить». Редьюсер делает переход именованной, исчерпывающей функцией; компоненты просто объявляют намерение через dispatch.

type CartItem = { id: string; qty: number };
type CartState = { items: CartItem[] };
type CartAction =
  | { type: "add"; id: string }
  | { type: "remove"; id: string }
  | { type: "clear" };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const existing = state.items.find((i) => i.id === action.id);
      const items = existing
        ? state.items.map((i) => (i.id === action.id ? { ...i, qty: i.qty + 1 } : i))
        : [...state.items, { id: action.id, qty: 1 }];
      return { items };
    }
    case "remove":
      return { items: state.items.filter((i) => i.id !== action.id) };
    case "clear":
      return { items: [] };
  }
}

Редьюсер — чистая функция без React внутри — тривиально юнит-тестируемая, а размеченное объединение делает необработанное действие ошибкой типа. Правило «добавление увеличивает количество» теперь существует ровно один раз.

2

Раздавай state и dispatch через два отдельных контекста — не через один объект. Наивная версия — <CartContext.Provider value={{ state, dispatch }}>. Проблема: этот объект value — новая ссылка на каждом рендере, так что каждый потребитель перерисовывается, даже если он только и делает, что вызывает dispatch. Разделение позволяет компоненту подписаться ровно на то, что ему нужно.

const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

Кнопка, которая только добавляет товары, читает контекст dispatch и никогда — контекст state, так что при изменении корзины эта кнопка не перерисовывается.

3

Контекст dispatch никогда не запускает перерисовки, потому что useReducer возвращает dispatch, чья идентичность стабильна на всё время жизни компонента. React гарантирует, что dispatch (как и обновляющая функция setState) ссылочно стабилен между рендерами. Контекст перерисовывает своих потребителей только тогда, когда value провайдера меняется по Object.is. Поскольку ссылка на dispatch никогда не меняется, value контекста dispatch никогда не меняется, а значит его потребители никогда не перерисовываются из-за него. Меняется только значение контекста state на каждое обновление, и перерисовываются только его читатели.

export function useCartDispatch() {
  const ctx = useContext(CartDispatchContext);
  if (!ctx) throw new Error("useCartDispatch must be used inside <CartProvider>");
  return ctx; // stable forever — safe to put in deps, safe to pass down
}

export function useCart() {
  const ctx = useContext(CartStateContext);
  if (!ctx) throw new Error("useCart must be used inside <CartProvider>");
  return ctx;
}

В этом весь трюк с эффективностью: писатели (триггеры действий) развязаны с читателями (отображениями состояния). В однотонной схеме с одним контекстом тебе пришлось бы хвататься за useMemo на значении и memo на детях, чтобы отыграть это назад; разделение даёт это структурно, бесплатно.

4

Знай режим отказа: этот паттерн — для умеренного клиентского состояния с настоящими переходами — не для серверного состояния и не для огромного / высокочастотного состояния. Два неверных применения топят его. Первое: положить сюда серверное состояние (каталог товаров, заказы пользователя) — значит вручную городить кэширование, перезапрос, протухание и дедупликацию, которые query-библиотека уже решает; у контекста + редьюсера нет понятия «эти данные пришли из сети и могут протухнуть». Второе: когда состояние велико или обновляется много раз в секунду (живой курсор, большой документ редактора, посимвольное состояние формы, читаемое сотнями узлов), даже подход с разделёнными контекстами перерисовывает каждого читателя состояния на каждое изменение, потому что контекст не умеет в избирательные подписки.

// WRONG: server state shoved into a reducer/context
dispatch({ type: "ordersLoaded", orders }); // who refetches? when is this stale?
// → use TanStack Query / SWR: useQuery(['orders', userId], fetchOrders)

// WRONG: high-frequency, selector-needing state in context
// every keystroke re-renders all 200 fields that read the form context
// → use an external store (Zustand/Redux + selectors, or useSyncExternalStore)

Контекст + редьюсер сидит посередине: больше, чем useState, поднятый на один уровень, меньше, чем стор. Подбирай его именно под эту полосу.

Разбор примера

Поток авторизации: от одного объекта-контекста к редьюсеру с разделёнными контекстами — и наблюдаем, как падают перерисовки. Начни с наивной версии, которую отгружают многие кодовые базы.

// BEFORE: one context, one object value — every consumer re-renders on any change
const AuthContext = createContext<{
  user: User | null;
  login: (u: User) => void;
  logout: () => void;
} | null>(null);

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  // new object every render → all consumers re-render even on unrelated parent renders
  const value = { user, login: setUser, logout: () => setUser(null) };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Две проблемы: логике переходов («токен обновлён» должно сохранить пользователя, но поменять токен) негде жить, кроме как в ещё более стихийных сеттерах, а value нестабилен, так что <LoginButton>, который никогда не читает user, всё равно перерисовывается всякий раз, когда user меняется. Теперь версия с редьюсером + разделённым контекстом:

type AuthState = { user: User | null; token: string | null };
type AuthAction =
  | { type: "login"; user: User; token: string }
  | { type: "logout" }
  | { type: "refreshed"; token: string };

function authReducer(s: AuthState, a: AuthAction): AuthState {
  switch (a.type) {
    case "login":     return { user: a.user, token: a.token };
    case "logout":    return { user: null, token: null };
    case "refreshed": return { ...s, token: a.token }; // keep user, swap token
  }
}

const AuthStateContext = createContext<AuthState | null>(null);
const AuthDispatchContext = createContext<React.Dispatch<AuthAction> | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, { user: null, token: null });
  return (
    <AuthStateContext.Provider value={state}>
      <AuthDispatchContext.Provider value={dispatch}>{children}</AuthDispatchContext.Provider>
    </AuthStateContext.Provider>
  );
}

// <LoginButton> calls useAuthDispatch() only → does NOT re-render when user changes
// <Avatar> calls useAuth() (state) → re-renders only when state actually changes

Переход «refreshed» теперь живёт в одном протестированном месте, а кнопка, которая только диспатчит, структурно изолирована от изменений состояния. Заметь, чего мы не делали: профильные данные пользователя и заказы — это серверное состояние, они остаются в query-библиотеке, а не в этом редьюсере. Этот контекст держит только идентичность сессии и переходы над ней.

Почему это работает

Почему разделять на два контекста, а не один контекст плюс useMemo на значении и React.memo на каждом потребителе? Оба варианта могут работать, но платят за один и тот же результат по-разному. Маршрут с одним контекстом требует помнить про мемоизацию объекта-значения и оборачивать потребителей в memo, и один забытый useMemo молча возвращает перерисовки-на-каждом-рендере. Разделение структурно: компонент физически не может подписаться на dispatch-и-заодно-state, пока не прочитает оба контекста, так что дешёвый случай (компоненты только на запись) дёшев по построению, а не по бдительности. Меньше шансов ошибиться — вот senior-причина.

Частая ошибка

Соблазнительный перегиб — относиться к контексту + редьюсеру как к «Redux, но встроенному» и заливать в него всё — серверные данные, производные значения, высокочастотное UI-состояние. Это не универсальный стор: у него нет селекторов (читатель состояния перерисовывается на любое изменение состояния, а не только на тот срез, который он использует) и нет async/кэширующей модели. Инстинкт, который надо спросить перед добавлением среза: это данные — собственное сессионное/UI-состояние клиента, умеренного размера, с ограниченной частотой обновлений? Если это данные, которыми владеет сеть, им место в query-кэше; если они велики или обновляются постоянно, а компонентам нужны срезы, им место в сторе с селекторами. Контекст + редьюсер — это средняя полоса, а не универсальный ответ.

Проверь себя
Викторина

Ты выставляешь редьюсер корзины через два контекста: CartStateContext (value = state) и CartDispatchContext (value = dispatch). Компонент <AddToCartButton> вызывает только useCartDispatch(). Корзина меняется при добавлении товара. Перерисуется ли AddToCartButton из-за контекста, и почему?

Итог

Контекст + useReducer — встроенный ответ React для умеренного сквозного клиентского состояния с нетривиальными переходами — больше, чем один поднятый useState, меньше, чем сторонний стор. Редьюсер кладёт каждый переход в одно чистое, тестируемое место; контекст доставляет его по дереву. Senior-ход — разделить state и dispatch на два контекста: поскольку dispatch из useReducer имеет стабильную идентичность между рендерами, значение контекста dispatch никогда не меняется, так что компоненты только на запись, читающие лишь dispatch, никогда не перерисовываются — читатели и писатели развязаны структурно, без бдительности с useMemo/memo. Оберни оба в один провайдер с типизированными хуками useCart() / useCartDispatch(), которые бросают исключение вне провайдера. И знай полосу: этот паттерн не годится, когда данные — на самом деле серверное состояние (используй query-библиотеку — у неё есть кэширование, протухание, перезапрос) или они огромны / высокочастотны и им нужны срезы (используй внешний стор с селекторами). Подбери его под середину — и это самый чистый стор без зависимостей, который ты можешь построить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.