javascript

React Context без лишних ререндеров: подписка на отдельные поля

  • воскресенье, 1 ноября 2026 г. в 00:00:06
https://habr.com/ru/articles/1102040/

Знакомая ситуация: в контексте лежат два поля, скажем, name и age. Меняется age, и ререндерится компонент, который читает только name. У useContext нет селектора, как у useSelector в Redux: React сравнивает value провайдера целиком, а при каждом изменении это новый объект. Ни useMemo, ни React.memo тут не спасают.

В статье — небольшой контекст, где компонент ререндерится, только когда меняется его собственное поле. И разбор, почему он устроен именно так:

  • Почему через контекст стоит передавать не сами данные, а стабильный объект доступа к ним?

  • Почему обычные приёмы — useMemo на value и раздельные контексты — до этого не дотягивают?

  • Зачем для подписки нужен useSyncExternalStore и что такое tearing?

  • На каких деталях легко ошибиться и когда такой контекст вообще нужен?

Проблема

Вот как это выглядит в коде: провайдер с двумя полями и два потребителя, каждый читает своё.

function FormProvider({ children }: { children: React.ReactNode }) {
  const [name, setName] = useState('Ann');
  const [age, setAge] = useState(30);

  return <FormContext value={{ name, age, setName, setAge }}>{children}</FormContext>;
}

function NameLabel() {
  const { name } = useContext(FormContext);
  return <span>{name}</span>;
}

function AgeInput() {
  const { age, setAge } = useContext(FormContext);
  
  return (
    <input type="number" value={age} onChange={(e) => setAge(+e.target.value)} />
  );
}

Каждый ввод в AgeInput ререндерит NameLabel, хотя name не менялся. Дело в том, что у useContext нет селектора, как у useSelector в Redux: React сравнивает value целиком через Object.is, а при смене age объект честно новый. Поэтому useMemo на value не поможет, и React.memo на потребителях тоже: обновление контекста приходит мимо пропсов.

Ниже — сразу решение: контекст, в котором NameLabel ререндерится только при смене name. А потом разберём, почему оно устроено именно так, почему обычные приёмы до него не дотягивают и зачем там useSyncExternalStore.

Все примеры — на React 19 (<Context value> вместо <Context.Provider value>), но идеи работают и в 18.

Решение: fast-context — внешний стор под видом контекста

Идея в том, чтобы перестать передавать через Context данные. Через него передаётся стабильный объект доступа к данным — { get, set, subscribe }, — ссылка на который не меняется никогда. Раз value провайдера не меняется, механизм контекста никого не ререндерит. А сами данные живут в useRef, и компонент подписывается ровно на то поле, которое читает.

Три составные части:

  1. Данные в useRef, а не в useState. Мутация ref не запускает ререндер провайдера, так что провайдер вообще не участвует в обновлениях.

  2. Стабильный API в Context. Объект { get, set, subscribe } создаётся один раз при монтировании и живёт, пока жив провайдер.

  3. Подписка на поле через useSyncExternalStore. Компонент регистрирует колбэк только для своего поля и читает значение через useSyncExternalStore — почему именно через него, разберём ниже.

import {
  createContext, useCallback, useContext, useRef, useSyncExternalStore,
} from 'react';

type Listener = () => void;

type FastStore<T extends object> = {
  get: () => T;
  set: (patch: Partial<T>) => void;
  subscribe: (field: keyof T, cb: Listener) => () => void;
};

export function createFastContext<T extends object>(initial: T) {
  const FastContext = createContext<FastStore<T> | null>(null);

  function FastProvider({ children }: { children: React.ReactNode }) {
    const stateRef = useRef<T>(initial);                        // данные вне React state
    const listeners = useRef(new Map<keyof T, Set<Listener>>());

    // объект создаётся один раз — ссылка в Context стабильна навсегда
    const store = useRef<FastStore<T>>({
      get: () => stateRef.current,
      set: (patch) => {
        const prev = stateRef.current;
        const next = { ...prev, ...patch };
        stateRef.current = next;
        (Object.keys(patch) as (keyof T)[]).forEach((field) => {
          if (Object.is(prev[field], next[field])) return;     // поле не изменилось — молчим
          listeners.current.get(field)?.forEach((cb) => cb());
        });
      },
      subscribe: (field, cb) => {
        let bucket = listeners.current.get(field);
        if (!bucket) listeners.current.set(field, (bucket = new Set()));
        bucket.add(cb);
        return () => bucket.delete(cb);
      },
    }).current;

    return <FastContext value={store}>{children}</FastContext>;
  }

  function useFastStore() {
    const store = useContext(FastContext);
    if (!store) throw new Error('Хук должен вызываться внутри FastProvider');
    return store;
  }

  function useFastField<K extends keyof T>(field: K): T[K] {
    const store = useFastStore();
    // стабильный subscribe — иначе React переподписывается на каждом рендере
    const subscribe = useCallback(
      (cb: Listener) => store.subscribe(field, cb),
      [store, field],
    );

    return useSyncExternalStore(
      subscribe,
      () => store.get()[field],   // клиент
      () => initial[field],       // сервер и гидрация
    );
  }

  function useFastSetter() {
    return useFastStore().set;
  }

  return { FastProvider, useFastField, useFastSetter };
}

Использование:

const { FastProvider, useFastField, useFastSetter } =
  createFastContext({ name: 'Ann', age: 30 });

// рендерится только при смене name
function NameLabel() {
  const name = useFastField('name');
  return <span>{name}</span>;
}

// рендерится только при смене age
function AgeInput() {
  const age = useFastField('age');
  const set = useFastSetter();
  return (
    <input type="number" value={age} onChange={(e) => set({ age: Number(e.target.value) })} />
  );
}

// только пишет — не рендерится вообще
function ResetButton() {
  const set = useFastSetter();
  return <button onClick={() => set({ name: '', age: 0 })}>Сбросить</button>;
}

export function App() {
  return (
    <FastProvider>
      <NameLabel />
      <AgeInput />
      <ResetButton />
    </FastProvider>
  );
}

Вводим возраст — в React DevTools Profiler подсвечивается один AgeInput. NameLabel, ResetButton и сам FastProvider не рендерятся.

Обычный приём 1: useMemo на value

useMemo на value стоит ставить в любом провайдере. Если провайдер ререндерился по посторонней причине, например вместе с родителем, ссылка на value останется прежней, и React вообще не запустит обновление потребителей.

Но от нашей проблемы он не спасает: при смене age объект честно новый, и NameLabel ререндерится, как в первом примере.

Обычный приём 2: раздельные контексты для значения и сеттера

Второй приём — не мемоизировать объект вовсе, а разнести значение и сеттер по разным контекстам. Сеттер из useState (как и dispatch из useReducer) React создаёт один раз и не пересоздаёт между рендерами — он стабилен по ссылке сам по себе, без всякого useCallback.

const UserValueContext = createContext<User | null>(null);
const UserSetterContext =
  createContext<Dispatch<SetStateAction<User | null>>>(() => {});

function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  return (
    <UserValueContext value={user}>
      <UserSetterContext value={setUser}>{children}</UserSetterContext>
    </UserValueContext>
  );
}

// только пишет — value его контекста не меняется никогда, рендеров нет
function LogoutButton() {
  const setUser = useContext(UserSetterContext);
  return <button onClick={() => setUser(null)}>Выйти</button>;
}

// читает — рендерится при смене user, это ожидаемо
function Greeting() {
  const user = useContext(UserValueContext);
  return <p>Привет, {user?.name}</p>;
}

Компоненты, которым нужно только вызвать сеттер (кнопка «Выйти», форма отправки), больше не дёргаются при каждом изменении user. Цена — два провайдера вместо одного; оправдано, когда в дереве много таких «write-only» потребителей.

Но обратите внимание, какую гранулярность это даёт: «кто читает» против «кто пишет». Гранулярности по полям здесь по-прежнему нет.

Можно, конечно, завести по контексту на каждое поле. Но поля редко бывают независимыми: «дата окончания не раньше даты начала», «при смене страны сбросить город», общий reducer, который одним действием меняет несколько полей. Разложив состояние по провайдерам, мы разрываем эту логику: она расползается по разным местам, а сеттеру одного поля приходится дотягиваться до соседних контекстов. Не говоря уже о пирамиде провайдеров на десятке полей.

В fast-context состояние остаётся одним объектом: set({ country, city: '' }) меняет связанные поля одним вызовом, а уведомляются подписчики только этих полей.

Почему именно useSyncExternalStore

До React 18 такой паттерн писали на useState + useEffect:

function useFastField<K extends keyof T>(field: K) {
  const store = useFastStore();
  const [snapshot, setSnapshot] = useState(() => store.get()[field]);

  useEffect(
    () => store.subscribe(field, () => setSnapshot(store.get()[field])),
    [store, field],
  );

  return snapshot;
}

Сначала про два слова. Рендер в React — это не отрисовка на экране: так называется момент, когда React вызывает ваши функции-компоненты и вычисляет, каким должен стать интерфейс. DOM при этом ещё не меняется. Коммит — следующий шаг: React применяет посчитанное к DOM, то есть реально меняет страницу, и запускает эффекты (useLayoutEffect — сразу после изменения DOM, useEffect — чуть позже). Рендер может быть прерван и начат заново, коммит — нет.

Пока рендер блокирующий, то есть выполняется до конца и не уступает поток браузеру, это безопасно: посреди него некому подменить значение в ref. Так рендерил React 17, и так же React 18/19 рендерят обычные обновления — от кликов, ввода, обычного setState. Прерываемым рендер становится только для конкурентных обновлений: startTransition, useDeferredValue, повторные попытки Suspense. Вот там и появляется проблема. Как именно React прерывает рендер, я разбирал в прошлой статье.

Tearing

Данные лежат в ref и мутируются напрямую — для React это внешний источник, о котором он ничего не знает. Конкурентный рендер может уступить поток браузеру, а в паузе обработчик события вызовет set(). Когда рендер продолжится, часть компонентов уже отрисована со старым значением, а оставшиеся прочитают из ref новое. На экране одновременно окажутся два разных состояния одного стора. Это и называется tearing — «разрыв» картинки.

Как useSyncExternalStore это ловит

Он не просто читает значение один раз, а перепроверяет снимок вокруг границы «рендер → коммит». Проверок две:

  1. До коммита. Если рендер был прерываемым, то есть уступал поток, React в конце снова вызывает getSnapshot() и сравнивает с тем, что использовал рендер. Разошлось — результат выбрасывается, и React синхронно рендерит заново. Устаревшее значение на экран не попадает.

  2. После коммита. Ещё одна проверка в эффекте ловит мутации, случившиеся после рендера, но до этой проверки, например из useLayoutEffect. Первая проверка такое не увидит: она проходит до коммита, а этот эффект выполняется уже внутри него. На один кадр на экране может остаться устаревшее значение, но одинаковое у всех компонентов — разорванной картинки не будет. Потом React досинхронизирует.

Почему не оставить одну вторую проверку? Она срабатывает после коммита, то есть несогласованный интерфейс уже оказался бы на экране, пусть и на один кадр. Первая проверка не даёт этому случиться: React выбрасывает готовый результат и считает рендер заново до коммита. А вторая нужна для того, чего первая увидеть не может, — мутаций внутри самого коммита и сразу после него.

А как React знает, что именно проверять? Всё дерево перебирать не нужно: каждый компонент, вызвавший useSyncExternalStore, хранит запись: снимок, который он получил при рендере, и свою getSnapshot.

  • Перед коммитом. Во время прерываемого рендера React помечает такие компоненты в дереве, а в конце обходит только помеченные ветки: вызывает у каждого getSnapshot() и сравнивает с записью.

  • После коммита. Каждый такой компонент при рендере добавляет себе эффект с проверкой — в тот же список, что и обычные useEffect. React выполняет его уже после коммита, чаще всего — после отрисовки (если обновление вызвано кликом или вводом, то сразу после коммита, до отрисовки). Эффект снова вызывает getSnapshot() компонента и сравнивает с тем, что компонент уже показал. Не совпало — только тогда React ставит компонент в очередь на повторный рендер. Дерево обходить не нужно: каждый компонент проверяет только самого себя. Этот повторный рендер всегда блокирующий — отсюда ограничение с transitions, о котором ниже.

Так проверяются ровно те компоненты, которые читают внешний стор.

Связка useState + useEffect так не умеет: она не встроена в фазу коммита и узнаёт об изменении постфактум, когда несогласованная картинка уже на экране.

Цена: transitions

У защиты есть обратная сторона, и она прописана в документации useSyncExternalStore: если внешний стор мутируют во время неблокирующего transition-обновления, React откатывается к блокирующему. Мутацию внешнего стора нельзя пометить как transition. Так что startTransition(() => set({ … })) не сделает обновление «фоновым» — для такого состояния лучше подходит обычный useState.

Детали, на которых легко ошибиться

Почему store обёрнут в useRef, а не в useMemo

Вспомним, как устроены провайдер и хук:

function FastProvider({ children }) {
  // get, set, subscribe — как в разделе «Решение»
  const store = useRef<FastStore<T>>({ get, set, subscribe }).current;
  return <FastContext value={store}>{children}</FastContext>;
}

function useFastField(field) {
  const store = useFastStore(); // внутри — useContext(FastContext)
  // ...
}

Хук читает контекст через useContext(FastContext), поэтому на него действует обычное правило контекста: сменилась ссылка на value — ререндер всех потребителей, ещё до того, как в дело вступит useSyncExternalStore. Если бы store был обычной константой в теле FastProvider (const store = { get, set, subscribe }), он пересоздавался бы на каждом рендере провайдера (а провайдер ререндерится, например, когда родитель передаст новые children), и вся конструкция теряла бы смысл.

Казалось бы, useMemo(() => ({ … }), []) подходит по духу. Но документация React прямо говорит: на useMemo можно полагаться только как на оптимизацию производительности, и React вправе выбросить кэш — например, если компонент засаспендился при первом монтировании. Здесь стабильность ссылки — не оптимизация, а условие корректности. Поэтому useRef(...).current: объект создаётся один раз, .current никто не переприсваивает.

subscribe (первый аргумент useSyncExternalStore) должен быть стабильным

В useFastField из раздела «Решение» функция subscribe обёрнута в useCallback. Если передать в useSyncExternalStore новую функцию subscribe, React отпишется и подпишется заново — это прямо описано в документации:

// ❌ новая функция на каждом рендере — переподписка каждый раз
useSyncExternalStore(cb => store.subscribe(field, cb), () => store.get()[field]);

// ✅ ссылка стабильна, пока не меняются store и field
const subscribe = useCallback(
  (cb: Listener) => store.subscribe(field, cb),
  [store, field],
);
useSyncExternalStore(subscribe, () => store.get()[field]);

Первый вариант ошибкой не будет, но работа лишняя. Со вторым аргументом, getSnapshot, такой проблемы нет. У нас это () => store.get()[field], он читает текущее значение поля. React вызывает его на каждом рендере, и новая функция ничего не ломает.

getSnapshot (второй аргумент useSyncExternalStore) обязан возвращать стабильное значение

Вот useFastField целиком:

function useFastField<K extends keyof T>(field: K): T[K] {
  const store = useFastStore();
  const subscribe = useCallback(
    (cb: Listener) => store.subscribe(field, cb),
    [store, field],
  );
  return useSyncExternalStore(
    subscribe,
    () => store.get()[field],   // getSnapshot: текущее значение поля
    () => initial[field],       // getServerSnapshot: для сервера и гидрации
  );
}

React сравнивает снимки через Object.is. Если getSnapshot (второй аргумент) каждый раз возвращает новый объект, сравнение никогда не проходит, и получается бесконечный цикл рендеров — в DEV React предупредит об этом в консоли. У нас getSnapshot возвращает значение поля как есть, а set пересобирает только верхний уровень, так что неизменённые поля-объекты сохраняют свои ссылки. Но селектор вида s => ({ a: s.a, b: s.b }) здесь сломал бы всё.

SSR и гидрация

Третий аргумент useSyncExternalStore, getServerSnapshot, в нашем коде равен () => initial[field]. Он используется при серверном рендере и при гидрации на клиенте и должен вернуть ровно то, что было на сервере, иначе разметка разойдётся. Поэтому отдаём initial[field], а не текущее состояние ref.

Отдельный список подписчиков на каждое поле — оптимизация, а не необходимость

В createFastContext подписчики хранятся по полям, и set находит нужных так:

const listeners = useRef(new Map<keyof T, Set<Listener>>()); // field → колбэки подписчиков

set: (patch) => {
  // ...
  (Object.keys(patch) as (keyof T)[]).forEach((field) => {
    if (Object.is(prev[field], next[field])) return;
    listeners.current.get(field)?.forEach((cb) => cb()); // только подписчики этого поля
  });
},

Честное уточнение: отдельный список на каждое поле — оптимизация, а не необходимость. Допустим, подписчики лежат в одном общем списке, и set({ age: 31 }) вызывает колбэки всех, в том числе колбэк NameLabel. Он вызовет getSnapshot, получит прежнее name, Object.is скажет «то же самое», и повторного рендера не будет. То есть решает сравнение снимков, а не список: лишних ререндеров не было бы и без отдельных списков. Списки экономят только сами вызовы: колбэка и getSnapshot у NameLabel, чьё поле не трогали. На паре подписчиков это незаметно, на сотнях разница уже заметна.

Что выбирать

Приём

От чего спасает

Гранулярность

Цена

useMemo на value

Ререндеры потребителей, когда провайдер перерисовался вместе с родителем, а данные не менялись

Весь value

Почти бесплатно; делать всегда

Раздельные value/setter

Ререндеры компонентов, которые только пишут

Чтение vs запись

Два провайдера

fast-context

Ререндеры от изменения чужих полей

Отдельное поле

Свой код подписок; transitions становятся блокирующими

Стейт-менеджер (Zustand, Jotai и т. п.)

Ререндеры от изменения чужих полей, как и у fast-context

Поле или селектор

Внешняя зависимость

Нативного селектора для контекста в стабильном React нет. Из готового есть use-context-selector от Daishi Kato — тот же подход в userland. У него честно оговорено, что защита от tearing работает, только если все потребители читают через его хук.

Когда fast-context уместен: состояние локально для поддерева (форма, редактор, виджет), таких поддеревьев на странице несколько, полей много. Для такого хватает небольшого кода без новых зависимостей. Если же состояние глобальное, разумнее взять готовый стейт-менеджер, например Zustand: в нём есть DevTools, и свой код подписок поддерживать не нужно.

Итог

Context — механизм доставки значения вниз по дереву, а не стор. Он сравнивает value целиком и не умеет подписывать на часть. Если нужна подписка на поля, через Context надо передавать не данные, а стабильный доступ к ним, а сами данные держать снаружи React. С этого момента это внешний стор, и читать его в конкурентном React безопасно только через useSyncExternalStore.

Могу сравнить fast-context с use-context-selector: в чём они похожи, где расходятся и когда какой подход выбрать. Или разобрать что-то другое по этой теме. Напишите в комментариях, что интереснее.