React Context без лишних ререндеров: подписка на отдельные поля
- воскресенье, 1 ноября 2026 г. в 00:00:06
Знакомая ситуация: в контексте лежат два поля, скажем, 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.
Идея в том, чтобы перестать передавать через 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 не рендерятся.
useMemo на value стоит ставить в любом провайдере. Если провайдер ререндерился по посторонней причине, например вместе с родителем, ссылка на value останется прежней, и React вообще не запустит обновление потребителей.
Но от нашей проблемы он не спасает: при смене age объект честно новый, и NameLabel ререндерится, как в первом примере.
Второй приём — не мемоизировать объект вовсе, а разнести значение и сеттер по разным контекстам. Сеттер из 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: '' }) меняет связанные поля одним вызовом, а уведомляются подписчики только этих полей.
До 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 прерывает рендер, я разбирал в прошлой статье.
Данные лежат в ref и мутируются напрямую — для React это внешний источник, о котором он ничего не знает. Конкурентный рендер может уступить поток браузеру, а в паузе обработчик события вызовет set(). Когда рендер продолжится, часть компонентов уже отрисована со старым значением, а оставшиеся прочитают из ref новое. На экране одновременно окажутся два разных состояния одного стора. Это и называется tearing — «разрыв» картинки.
Он не просто читает значение один раз, а перепроверяет снимок вокруг границы «рендер → коммит». Проверок две:
До коммита. Если рендер был прерываемым, то есть уступал поток, React в конце снова вызывает getSnapshot() и сравнивает с тем, что использовал рендер. Разошлось — результат выбрасывается, и React синхронно рендерит заново. Устаревшее значение на экран не попадает.
После коммита. Ещё одна проверка в эффекте ловит мутации, случившиеся после рендера, но до этой проверки, например из useLayoutEffect. Первая проверка такое не увидит: она проходит до коммита, а этот эффект выполняется уже внутри него. На один кадр на экране может остаться устаревшее значение, но одинаковое у всех компонентов — разорванной картинки не будет. Потом React досинхронизирует.
Почему не оставить одну вторую проверку? Она срабатывает после коммита, то есть несогласованный интерфейс уже оказался бы на экране, пусть и на один кадр. Первая проверка не даёт этому случиться: React выбрасывает готовый результат и считает рендер заново до коммита. А вторая нужна для того, чего первая увидеть не может, — мутаций внутри самого коммита и сразу после него.
А как React знает, что именно проверять? Всё дерево перебирать не нужно: каждый компонент, вызвавший useSyncExternalStore, хранит запись: снимок, который он получил при рендере, и свою getSnapshot.
Перед коммитом. Во время прерываемого рендера React помечает такие компоненты в дереве, а в конце обходит только помеченные ветки: вызывает у каждого getSnapshot() и сравнивает с записью.
После коммита. Каждый такой компонент при рендере добавляет себе эффект с проверкой — в тот же список, что и обычные useEffect. React выполняет его уже после коммита, чаще всего — после отрисовки (если обновление вызвано кликом или вводом, то сразу после коммита, до отрисовки). Эффект снова вызывает getSnapshot() компонента и сравнивает с тем, что компонент уже показал. Не совпало — только тогда React ставит компонент в очередь на повторный рендер. Дерево обходить не нужно: каждый компонент проверяет только самого себя. Этот повторный рендер всегда блокирующий — отсюда ограничение с transitions, о котором ниже.
Так проверяются ровно те компоненты, которые читают внешний стор.
Связка useState + useEffect так не умеет: она не встроена в фазу коммита и узнаёт об изменении постфактум, когда несогласованная картинка уже на экране.
У защиты есть обратная сторона, и она прописана в документации useSyncExternalStore: если внешний стор мутируют во время неблокирующего transition-обновления, React откатывается к блокирующему. Мутацию внешнего стора нельзя пометить как transition. Так что startTransition(() => set({ … })) не сделает обновление «фоновым» — для такого состояния лучше подходит обычный useState.
Вспомним, как устроены провайдер и хук:
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 никто не переприсваивает.
В 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 вызывает его на каждом рендере, и новая функция ничего не ломает.
Вот 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 }) здесь сломал бы всё.
Третий аргумент 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: в чём они похожи, где расходятся и когда какой подход выбрать. Или разобрать что-то другое по этой теме. Напишите в комментариях, что интереснее.