open atlas
↑ К треку
React с нуля до senior RCT · 02 · 03

Контекст, идентичность value и шторм ререндеров

Контекст перерисовывает каждого потребителя при смене идентичности value у провайдера — инлайновый объект value — это шторм на каждом рендере, и memo предков его не блокирует. Лечения: мемоизация value, разделение контекстов state/dispatch или внешний стор с подписками на срезы.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Тикет гласил: «глобальный поиск лагает при вводе». Profiler говорил нечто более странное: каждое нажатие клавиши перерисовывало 1200 компонентов — сайдбар, колокольчик уведомлений, футер, три таблицы данных, не имевшие к поиску никакого отношения. Поле поиска жило в каркасе приложения, рядом с этой строкой: <AppContext.Provider value={{ user, theme, permissions, setTheme }}>. Каждое нажатие обновляло состояние каркаса, каркас перерисовывался, объектный литерал value пересоздавался — новая идентичность — и все 38 компонентов, потребляющих AppContext, перерисовывались, таща за собой свои поддеревья. Жестокая деталь: половине этих потребителей был нужен только setTheme — функция, чьё поведение не менялось с момента написания приложения. А обёртки React.memo, которые кто-то навесил на таблицы, не делали ничего, потому что доставка контекста не проходит через ворота props: memo может остановить распространение ререндера родителя, но не может остановить обновление контекста на пути к подписанному потомку. Команда обращалась с контекстом как со стором. Контекст — не стор; это широковещательный канал, замкнутый на одно сравнение идентичности.

Механизм: одна проверка идентичности, затем broadcast

Распространение контекста брутально просто. Когда провайдер перерисовывается, React сравнивает новый prop value с предыдущим через Object.is. Если он отличается, React обходит поддерево, находит каждый компонент, читающий этот контекст — через useContext или use, — и планирует каждому ререндер. Два свойства этого обхода определяют каждый контекстный баг, который вы встретите. Первое: доставка обходит bailout-ы memo. React.memo и поверхностно равные props могут остановить спуск родительских ререндеров, но обновление контекста доставляется подписанным потомкам сквозь мемоизированных предков — сами предки остаются пропущенными, а потребитель под ними всё равно перерисовывается. Это by design: потребитель попросил значение, значит, обязан получить новое. Второе: селекторов нет — потребитель не может подписаться только на value.theme. Любая смена идентичности целого value перерисовывает каждого потребителя, изменился читаемый им срез или нет.

Теперь шторм из Hook следует механически. value={{ user, theme, permissions, setTheme }} — объектный литерал в рендере провайдера, свежая идентичность на каждом рендере. Значит, ререндер провайдера по любой причине (нажатие клавиши в несвязанном состоянии, обновление родителя) означает: Object.is(prev, next) — false, все 38 потребителей перерисовываются, их поддеревья — тоже. Содержимое не изменилось; изменилась идентичность обёртки, а идентичность — единственное, что проверяет React.

// ❌ широковещательный шторм: новая идентичность value на каждом рендере каркаса
function AppShell() {
  const [query, setQuery] = useState("");
  return (
    <AppContext.Provider value={{ user, theme, permissions, setTheme }}>
      <SearchInput value={query} onChange={setQuery} />
      <Routes />
    </AppContext.Provider>
  );
}

// ✅ лечение 1: стабильная идентичность — вещание только при смене содержимого
const appValue = useMemo(
  () => ({ user, theme, permissions, setTheme }),
  [user, theme, permissions] // setTheme из useState и так стабилен
);

// ✅ лечение 0 (часто лучше): опустить несвязанное состояние ВНИЗ —
// поле поиска владеет своим query; провайдер вообще не рендерится на нажатия.
Викторина

Потребитель ThemeContext сидит под компонентом в React.memo, чьи props никогда не меняются. Провайдер перерисовывается с новой идентичностью value. Что происходит с потребителем?

Разделение контекстов: state отдельно от dispatch

У самой жестокой детали из Hook — потребители setTheme, перерисовывающиеся при каждом изменении данных, — есть структурное лечение. Функции-обновители состояния имеют стабильные идентичности (setState из useState и dispatch из useReducer гарантированно стабильны), поэтому компонентам, которые только запускают изменения, не нужно перерисовываться при смене данных. Положите данные и обновители в два отдельных контекста:

const StateContext = createContext(null);
const DispatchContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

Теперь кнопка тулбара, вызывающая dispatch, подписана только на DispatchContext, чьё value никогда не меняет идентичность, — она перерисовывается ровно ноль раз при обновлениях состояния. Компоненты с интенсивным чтением подписаны на StateContext и перерисовываются, когда состояние действительно изменилось (редьюсер возвращает новый объект, только если что-то поменялось). Два уточнения, на которых держатся реальные приложения: разделяйте и по доменам — ThemeContext отдельно от AuthContext отдельно от PermissionsContext, — чтобы обновление авторизации не перерисовывало каждую темизированную кнопку; и паттерн {children} выше — сам по себе оптимизация: children создаются вызывающим AppProvider, поэтому при изменении собственного состояния AppProvider идентичность элемента children не меняется, и React может пропустить ререндер этого поддерева целиком — провайдер перерисовывается, статичное дерево под ним нет.

Выбор канала: props, контекст или внешний стор

Три механизма доставки — три честных критерия выбора. Прокидывание props — явный поток данных: каждый прыжок виден, рефакторинг прослеживаем, скрытых подписок нет. На 2–3 уровня это просто правильно, а композиция компонентов (передача JSX как children) убирает большую часть «боли прокидывания» вообще без контекста. Его цена — церемония через много уровней и ререндеры вдоль цепочки без мемоизации. Контекст создан для значений с низкой частотой и широким веером: тема, локаль, текущий пользователь, положение роутера, фиче-флаги — то, что меняется несколько раз за сессию и что читают десятки компонентов. Его жёсткие пределы: нет селекторов (каждый потребитель перерисовывается при любой смене идентичности value), и цена обновления пропорциональна числу потребителей. Внешние сторы (Zustand, Redux, Jotai — все построены на useSyncExternalStore) держат состояние вне React и дают каждому компоненту подписку с селектором: ререндер только когда selector(state) вернул значение, отличное от прошлого. Это и есть гранулярность подписки — то, чего контекст дать не может, — и именно она делает высокочастотное состояние (фильтры на уровне нажатий, websocket-тикеры, позиции перетаскивания) жизнеспособным в масштабе. Хук стора сравнивает выбранный срез, а не весь объект состояния, поэтому трейдинговая таблица перерисовывает только строки, чей тикер сдвинулся.

Когда тянешься к контексту, спроси: как часто меняется это значение и скольким компонентам оно нужно? Решение за один проход: меняется редко, читается широко → контекст. Меняется постоянно, или разным потребителям нужны разные срезы → внешний стор с селекторами. Видимые 2–3 прыжка → просто передайте props; тянитесь к машинерии, лишь когда явная версия измеримо болит. Типовой продакшен-провал — обратное: состояние приложения любой частоты свалено в один контекст «чтобы не тащить Redux», что заново выводит шторм из Hook, — а затем внутри провайдера вырастает самодельная система подписок, то есть внешний стор, написанный плохо, без гарантий useSyncExternalStore от tearing.

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

Почему подписке на внешний стор вообще нужен особый хук — почему не читать стор в рендере и не подписываться в useEffect? Из-за конкурентного рендеринга. React может приостанавливать, перемежать и переигрывать рендеры; при уступающем рендере компонент A может прочитать версию стора 5, стор обновится до версии 6 посреди рендера, и компонент B прочитает версию 6 — один закоммиченный UI, показывающий две версии одного состояния. Это tearing (разрыв согласованности: разные части UI рендерят разные версии одного стора одновременно). useSyncExternalStore существует, чтобы его исключить: вы отдаёте React subscribe и getSnapshot, а React читает снимок в согласованной точке, перепроверяет его перед коммитом и откатывается к синхронному ререндеру, если стор изменился посреди прохода — согласованность ценой депприоритизации этого обновления. Поэтому самодельная версия на эффектах, которая «отлично работала» в React 17, тихо неверна при конкурентных возможностях React 18+, и поэтому каждая серьёзная стор-библиотека переехала на этот хук.

Викторина

После разделения AppContext на StateContext и DispatchContext (dispatch из useReducer) — как часто перерисовывается кнопка, которая только вызывает dispatch, при обновлениях состояния?

Вспомните перед уходом
  1. 01
    Проследите механизм шторма ререндеров контекста и два структурных лечения.
  2. 02
    Назовите критерии выбора props против контекста против внешнего стора и что добавляет useSyncExternalStore.
Итог

Распространение контекста — одно сравнение идентичности и затем broadcast: когда провайдер перерисовывается, React выполняет Object.is по value, и если тот изменился, каждый компонент, читающий этот контекст, планируется на ререндер — с доставкой сквозь предков в React.memo, потому что memo стережёт поток props от родителя, а контекст — отдельный канал подписки, и без селекторов, потому что React никогда не заглядывает в поля value. Отсюда механически следует шторм: инлайновый объект value — новая идентичность на каждом рендере провайдера, поэтому любое несвязанное изменение состояния выше провайдера перерисовывает всех потребителей с поддеревьями — 1200 компонентов на нажатие клавиши в истории из начала урока. Лечения структурны прежде, чем хитры: опустить несвязанное состояние вниз, чтобы провайдер перестал перерисовываться; передавать children, чтобы статичное поддерево сохраняло идентичность элемента и пропускалось целиком; useMemo на объекте value, чтобы идентичность менялась только вместе с содержимым; и разделение контекстов — данные в StateContext, обновители в DispatchContext, — эксплуатирующее гарантию React о стабильности идентичностей dispatch и setState: компоненты, лишь запускающие изменения, не перерисовываются вовсе при бурлении данных, а разделение по доменам (тема, авторизация, права) срезает веер дальше. Выбор канала — решение о частоте и веере: props на два-три видимых прыжка, контекст для редких широковещательных значений вроде темы и локали, внешний стор для высокочастотного или нарезаемого состояния, где useSyncExternalStore даёт каждому компоненту подписку-селектор — ререндер только при изменении своего среза — плюс защиту от tearing при конкурентном рендеринге, где перемежённые рендеры иначе могли бы закоммитить две версии одного стора. Свалить состояние всех частот в один контекст — заново вывести шторм; вырастить самодельную систему подписок внутри провайдера — переизобрести стор без его гарантий согласованности. Теперь, когда встречаешь лаг при вводе и Profiler показывает сотни ререндеров — смотри на prop value провайдера первым делом: инлайновый объект там — самая частая причина, по которой двигается всё дерево.

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.