Контекст для низкочастотного состояния
Контекст вещает всем потребителям при каждом изменении значения, поэтому подходит низкочастотному, широко читаемому состоянию — тема, пользователь, локаль, — а быстро меняющееся превращает в шторм перерисовок на всё дерево.
Контекст выглядит как очевидное решение в тот момент, когда пропс должен пройти через пять компонентов, которым он не нужен. Ты оборачиваешь Provider около корня, вызываешь useContext в листе — и проброс пропсов исчез. Это ощущается бесплатной победой, поэтому к нему тянутся снова — для черновых значений формы, для позиции курсора, для текущей наведённой ячейки.
Вторая волна — там, где команды обжигаются. Контекст — это не быстрый пропс; это канал вещания. Когда его значение меняется, каждый потребитель в поддереве перерисовывается, и React не даёт способа подписаться на «только ту часть, что я читаю». Паттерн, стёрший проброс пропсов для твоей темы, при применении к быстро меняющемуся значению перерисует треть твоего дерева на каждое нажатие клавиши. Этот урок — про то, как понять, в какой из двух ситуаций ты находишься.
После этого урока ты можешь сформулировать правило, решающее пригодность контекста, — контекст подходит низкочастотному, широко читаемому состоянию — и объяснить его из механизма: изменение значения контекста безусловно перерисовывает всех потребителей. Ты можешь построить правильный ThemeContext со стабильным значением, которое далёкие компоненты читают дёшево. И ты можешь распознать режим отказа — высокочастотное состояние (значения полей, позиция мыши, кадры анимации) в контексте, вызывающее шторм перерисовок, — и назвать, где это состояние должно жить вместо этого.
Изменение значения контекста перерисовывает каждого потребителя — частичной подписки нет. Когда value у Provider — новая ссылка, React помечает каждый компонент, вызвавший useContext для этого контекста, как требующий перерисовки. Не имеет значения, что потребитель читает только value.theme, а изменилось value.locale; единица изменения — это всё значение целиком, а единица уведомления — «все потребители». React.memo тебя не спасёт: мемоизированный компонент всё равно перерисовывается, когда обновляется потребляемый им контекст, потому что контекст — отдельный канал от пропсов.
const AppContext = createContext<{ theme: Theme; locale: string } | null>(null);
// reads only `theme`, but re-renders whenever `locale` changes too —
// because the value reference changed, and memo can't block a context update
const ThemeBadge = memo(function ThemeBadge() {
const { theme } = useContext(AppContext)!;
return <span data-theme={theme} />;
});Это единственный факт, на котором держится весь паттерн: контекст — это вещание, всё-или-ничего, каждому потребителю.
Поскольку изменение вещается, контекст подходит состоянию, которое меняется редко и читается широко. Тема, аутентифицированный пользователь, локаль, feature-флаги — они переключаются с интервалом в секунды или минуты (или раз за сессию) и нужны в десятках разбросанных мест. Вещание на каждое изменение дёшево, когда изменения редки, а пробрасывать их пропсами было бы мучительно. Это ровно та форма, под которую контекст и был спроектирован: низкочастотное, широко читаемое. Стоимость перерисовки реальна, но платится почти никогда; стоимость проброса пропсов избегается постоянно.
// good fit: changes on a user click, read all over the app
const ThemeContext = createContext<ThemeApi | null>(null);
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within <ThemeProvider>");
return ctx;
}Если твоё кандидатное состояние меняется много раз в секунду — это противоположная форма, и контекст здесь неправильный инструмент, как бы широко его ни читали.
Правильно: держи значение провайдера стабильным, чтобы потребители перерисовывались только на реальных изменениях. Стоимость контекста платится всякий раз, когда меняется ссылка value, — поэтому нестабильное значение (свежий объектный литерал на каждый рендер) перерисовывает всех потребителей на каждом рендере родителя, даже когда ничего значимого не изменилось. Мемоизируй значение и держи сеттеры стабильными, чтобы они никогда не форсировали изменение значения. Чистый ThemeContext переключает значение только тогда, когда тема действительно переключается.
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// toggle is stable; useState's setter identity is already stable
const toggle = useCallback(() => setTheme((t) => (t === "light" ? "dark" : "light")), []);
// value reference changes ONLY when `theme` changes → consumers re-render only then
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}Без useMemo { theme, toggle } — новый объект на каждом рендере ThemeProvider, так что любое несвязанное изменение состояния около корня вещало бы каждому потребителю впустую.
Режим отказа: положи высокочастотное состояние в контекст — и каждый потребитель становится штормом перерисовок. Значения полей, меняющиеся на каждое нажатие клавиши, позиция мыши, меняющаяся на каждый mousemove, кадр анимации, меняющийся 60 раз в секунду, — проведи любое из них через контекст, и каждый потребитель перерисовывается с этой частотой, включая те, что даже не читают меняющееся поле. Это классический анти-паттерн контекста: «контекст формы», хранящий живые значения ввода, где набор одного символа перерисовывает каждое поле, кнопку отправки и сводку валидации.
// ANTI-PATTERN: live values in context → typing re-renders the whole form tree
const FormContext = createContext<{ values: Record<string, string>; set: Fn } | null>(null);
function Field({ name }: { name: string }) {
const { values, set } = useContext(FormContext)!; // re-renders on EVERY keystroke,
return <input value={values[name]} onChange={(e) => set(name, e.target.value)} />; // in any field
}Решение — не мемоизировать сильнее, а перестать вещать быстрое состояние. Держи высокочастотное состояние локальным (каждое поле владеет своим useState), поднимай только то, что действительно общее, или используй стор с подписками по селекторам (useSyncExternalStore, Zustand, Jotai), чтобы компонент перерисовывался только когда меняется его срез. Контекст вещает; быстрому состоянию нужна адресная подписка.
Тема, сделанная правильно, против формы, сделанной неправильно, — один API, противоположные исходы. Начни с темы. Значение переключается только на осознанное действие пользователя и читается далёкими, несвязанными компонентами:
// ThemeProvider near the root; value is memoized so it changes only on theme flip
function Page() {
return (
<ThemeProvider>
<Header /> {/* reads theme for the logo */}
<Sidebar /> {/* reads theme for icons */}
<Editor /> {/* reads theme for the canvas */}
</ThemeProvider>
);
}
function Header() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>{theme === "light" ? "🌙" : "☀️"}</button>;
}Переключение перерисовывает Header, Sidebar, Editor — ровно один раз, на редкий клик. Это контекст, работающий как задумано: вещание — это фича, потому что всем троим действительно нужна новая тема.
Теперь ошибка: та же форма, применённая к живым значениям формы. FormProvider хранит { values, setValue }; каждое Field его потребляет. Набор одной буквы в поле email меняет values, что меняет значение контекста, что перерисовывает каждое поле, кнопку отправки и сводку валидации — на каждое нажатие клавиши.
// BEFORE (storm): one keystroke re-renders the whole form
<FormProvider>
<Field name="email" /> <Field name="password" /> {/* all re-render per keystroke */}
<SubmitButton /> <ValidationSummary />
</FormProvider>
// AFTER (calm): each field owns its own state; context holds only stable handles
function Field({ name }: { name: string }) {
const [value, setValue] = useState(""); // local, fast state stays here
const { register } = useForm(); // context value is stable: never changes on keystroke
return <input value={value} onChange={(e) => { setValue(e.target.value); register(name, e.target.value); }} />;
}Тот же API createContext / useContext, но версия AFTER держит быстро меняющееся значение вне канала вещания. Поле перерисовывается на свои нажатия клавиш (локальное состояние, неизбежно и дёшево); остальная форма — уже нет. Паттерн не изменился — изменилась частота значения в нём, и это всё решение целиком.
▸Почему это работает
Почему React.memo не чинит болтливый контекст? Потому что memo сравнивает только пропсы. Контекст доставляется отдельным путём: потребитель подписывается на контекст, и когда значение провайдера меняется, React планирует перерисовку этого потребителя независимо от того, изменились ли его пропсы. Так что обёрнутый в memo компонент, сидящий в болтливом контексте, всё равно перерисовывается на каждое изменение значения. Memo и контекст ортогональны — memo стережёт дверь пропсов, контекст входит в другую. Единственные реальные рычаги: менять значение реже (стабилизировать его) или перейти на модель подписки, позволяющую каждому потребителю читать только свой срез.
▸Частая ошибка
Тонкая версия шторма: расщепление состояния и dispatch в один контекст, чтобы «делиться всем», а затем размещение быстрого состояния рядом с медленным в одном значении. Даже если компонентам нужна только медленная часть, быстрая часть, перетряхивающая значение, перерисовывает их всех. Стандартное средство — разделить контексты по частоте изменений: например, стабильный DispatchContext (сеттеры, никогда не меняются) отдельно от StateContext, а внутри состояния — отделить редко меняющийся срез от быстрого. Компоненты подписываются только на канал, чью частоту они могут себе позволить. Один мега-контекст, несущий обе частоты, — это то, как «чистый» рефакторинг молча возвращает шторм.
Коллега переносит живые значения полей формы в FormContext, чтобы любой компонент мог их читать, и оборачивает каждое Field в React.memo для скорости. Набор в одном поле всё равно перерисовывает каждое поле. Почему?
Контекст — это канал вещания: когда меняется ссылка value провайдера, каждый потребитель перерисовывается, без частичной подписки и без спасения от React.memo (который сравнивает только пропсы). Этот механизм — ровно та причина, почему контекст подходит низкочастотному, широко читаемому состоянию — тема, аутентифицированный пользователь, локаль, feature-флаги, — где изменения редки, а чтения разбросаны, так что вещание платится почти никогда. Строй такой контекст со стабильным, мемоизированным значением, чтобы потребители перерисовывались только на реальных изменениях. Режим отказа — зеркальное отражение: проведи высокочастотное состояние (значения полей, позиция мыши, кадры анимации) через контекст, и каждое нажатие клавиши или кадр становится штормом перерисовок на всё дерево. Решение — не больше memo, а держать быстрое состояние локальным, поднимать только общее или использовать стор на селекторах, чтобы каждый потребитель перерисовывался только на своём срезе. Подбирай канал под частоту: вещание для редкого-и-глобального, адресная подписка для быстрого.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.