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

Знакомая ситуация: в контексте лежат два поля, скажем, 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, и компонент подписывается ровно на то поле, которое читает.
Три составные части:
Данные в
useRef, а не вuseState. Мутация ref не запускает ререндер провайдера, так что провайдер вообще не участвует в обновлениях.Стабильный API в Context. Объект
{ get, set, subscribe }создаётся один раз при монтировании и живёт, пока жив провайдер.Подписка на поле через
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 это ловит
Он не просто читает значение один раз, а перепроверяет снимок вокруг границы «рендер → коммит». Проверок две:
До коммита. Если рендер был прерываемым, то есть уступал поток, React в конце снова вызывает
getSnapshot()и сравнивает с тем, что использовал рендер. Разошлось — результат выбрасывается, и React синхронно рендерит заново. Устаревшее значение на экран не попадает.После коммита. Ещё одна проверка в эффекте ловит мутации, случившиеся после рендера, но до этой проверки, например из
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, чьё поле не трогали. На паре подписчиков это незаметно, на сотнях разница уже заметна.
Что выбирать
Приём | От чего спасает | Гранулярность | Цена |
|---|---|---|---|
| Ререндеры потребителей, когда провайдер перерисовался вместе с родителем, а данные не менялись | Весь | Почти бесплатно; делать всегда |
Раздельные 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: в чём они похожи, где расходятся и когда какой подход выбрать. Или разобрать что-то другое по этой теме. Напишите в комментариях, что интереснее.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.