Разделение контекстов
Гранулярность контекста задаёт радиус поражения перерисовки: один жирный контекст перерисовывает каждого потребителя на любое изменение. Дели по ответственности, отделяй state от сеттеров и стабилизируй значение через useMemo — не сползая в provider hell.
Ты потянулся к контексту, чтобы убить проброс пропсов, и это сработало — user, theme, locale и дюжина сеттеров текут из одного AppContext в корне. Потом загорается профайлер: переключение тёмной темы перерисовывает корзину, панель чата перерисовывается, когда подгружается имя пользователя, а флип настройки перекрашивает всё приложение. Ничего не сломано. Всё просто перерисовывается на изменения, до которых ему нет дела.
Это не баг контекста. Это баг гранулярности. Форма твоего контекста — сколько ты напихал в один провайдер, отделяешь ли ты читаемое состояние от писателей — напрямую управляет тем, как далеко расходится одно изменение. Этот урок — про то, чтобы выбирать эту форму осознанно.
После этого урока ты можешь объяснить, почему значение контекста — это его радиус поражения перерисовки: каждый потребитель провайдера перерисовывается, когда значение этого провайдера меняет идентичность; разделить один жирный контекст на сфокусированные контексты по ответственности, чтобы потребитель перерисовывался только ради того среза, который читает; отделить состояние от его dispatch/сеттеров в два контекста, чтобы компоненты «только на запись» не перерисовывались на изменения состояния; стабилизировать каждое передаваемое значение через useMemo, чтобы оно не получало новую идентичность на каждом рендере; и распознавать режим отказа — переразбиение в башню вложенных провайдеров — и находить баланс.
Значение контекста — это его радиус поражения перерисовки: каждый потребитель перерисовывается, когда идентичность значения меняется. React не сравнивает внутренности твоего значения контекста. Когда переданное провайдеру значение — новая ссылка, каждый компонент, вызывающий useContext на нём, перерисовывается — независимо от того, какое поле он реально читает. Так что гранулярность твоего контекста и есть гранулярность твоих перерисовок.
// One context, everything in it. Any change → new value object →
// EVERY useContext(AppContext) consumer re-renders, even ones that
// only read `theme` when it was `user` that changed.
const AppContext = createContext<AppState | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<"light" | "dark">("light");
// a fresh object literal every render → identity changes every render
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}Корзина читает user. Шапка читает theme. Оба подписаны на один и тот же контекст, поэтому оба перерисовываются на любое из изменений. Радиус поражения — «все».
Дели по ответственности: один контекст на каждый независимо меняющийся срез. Состояние авторизации и состояние темы меняются с совершенно разной частотой и читаются разными частями дерева. Дай каждому свой контекст, и потребитель подпишется только на тот срез, который реально читает — и перерисуется только ради него.
const AuthContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | null>(null);
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
function AppProviders({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<Theme>("light");
return (
<AuthContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
</AuthContext.Provider>
);
}Теперь useContext(ThemeContext) в шапке перерисовывается только на изменения темы; корзина, читающая useContext(AuthContext), не затронута, когда пользователь переключает тёмную тему. Радиус поражения сжался с «все» до «все, кто читает этот срез». Ось, по которой делить, — что меняется вместе, а что независимо, а не как данные нарисованы на доске.
Отделяй состояние от его сеттеров: писателям не нужно значение. Компоненту, который только меняет тему — кнопке-переключателю, — незачем перерисовываться, когда тема меняется. Но если theme и setTheme живут в одном контексте, он перерисовывается всё равно. Раздели читаемое состояние в один контекст, а dispatch/сеттеры — в другой. Сеттеры стабильны на всю жизнь компонента, поэтому значение dispatch-контекста никогда не меняет идентичность — его потребители перерисовываются практически никогда.
const ThemeStateContext = createContext<Theme | null>(null);
const ThemeDispatchContext = createContext<((t: Theme) => void) | null>(null);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// setTheme from useState is stable across renders, so the dispatch
// context value is referentially stable → its consumers don't re-render.
return (
<ThemeStateContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={setTheme}>
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
);
}
// A toggle button reads ONLY dispatch → never re-renders when theme changes.
function ThemeToggle() {
const setTheme = useContext(ThemeDispatchContext)!;
return <button onClick={() => setTheme((prev) => (prev === "dark" ? "light" : "dark"))}>Toggle</button>;
}Это тот же приём, что формализует useReducer + контекст: контекст state и контекст dispatch. dispatch стабилен навсегда, поэтому потребители «только на запись» не стоят ничего.
Стабилизируй передаваемое значение через useMemo, иначе разделение не даёт ничего. Провайдер, чьё значение — свежий объектный литерал, получает новую идентичность на каждом рендере провайдера, поэтому каждый потребитель перерисовывается каждый раз, когда провайдер перерисовывается — даже если ни одно поле реально не изменилось. Разделение контекстов не помогает, если каждый осколок всё равно спускает вниз нестабильный объект. Мемоизируй значение по его реальным зависимостям.
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// Without useMemo this object is new every render → identity churn.
// With it, consumers re-render only when `user` actually changes
// (setUser is already stable, so it's not in the dep list's intent).
const value = useMemo(() => ({ user, setUser }), [user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}Разделение state/dispatch — это более чистая версия того же: голое значение theme и голый стабильный setTheme не нуждаются в useMemo вовсе, потому что ни одно из них не объектный литерал. Тянись к useMemo, когда значение обязано быть собранным объектом (например, { user, setUser }); тянись к разделению, когда можешь избежать объекта целиком.
Один AppContext → сфокусированные контексты, с состоянием, отделённым от сеттеров. Корень дашборда предоставляет user, theme и их сеттеры из единственного контекста. Корзина читает user, шапка читает theme, а переключатель настроек только пишет тему.
До — один контекст, одно нестабильное значение, радиус поражения «все»:
const AppContext = createContext<{
user: User | null; setUser: (u: User | null) => void;
theme: Theme; setTheme: (t: Theme) => void;
} | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<Theme>("light");
// new object every render; toggling theme re-renders the cart too
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
function ThemeToggle() {
const { setTheme } = useContext(AppContext)!; // re-renders on user changes, for nothing
return <button onClick={() => setTheme("dark")}>Dark</button>;
}После — деление по ответственности, затем деление состояния темы от её dispatch:
const AuthContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | null>(null);
const ThemeStateContext = createContext<Theme | null>(null);
const ThemeDispatchContext = createContext<((t: Theme) => void) | null>(null);
function AppProviders({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<Theme>("light");
const auth = useMemo(() => ({ user, setUser }), [user]); // object → memoized
return (
<AuthContext.Provider value={auth}>
<ThemeStateContext.Provider value={theme}> {/* bare value, no memo */}
<ThemeDispatchContext.Provider value={setTheme}> {/* stable setter, no memo */}
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
</AuthContext.Provider>
);
}
function ThemeToggle() {
const setTheme = useContext(ThemeDispatchContext)!; // reads dispatch only → never re-renders on theme/user
return <button onClick={() => setTheme("dark")}>Dark</button>;
}Теперь переключение темы перерисовывает только компоненты, читающие ThemeStateContext. Корзина (читает AuthContext) и переключатель (читает ThemeDispatchContext) остаются на месте. Три провайдера вместо одного — правильное количество церемонии здесь: каждая граница покупает реальное сокращение радиуса поражения.
▸Почему это работает
Почему React перерисовывает всех потребителей при изменении значения, а не только тех, кто читает изменённое поле? Потому что распространение контекста основано на идентичности, а не на полях: провайдер держит одно значение, а useContext подписывается на ссылку этого значения. React понятия не имеет, какие поля деструктурирует данный потребитель, поэтому не может уведомлять выборочно. Этот единственный факт устройства — почему гранулярность это инструмент, которым ты владеешь вручную: разделение контекстов — это то, как ты говоришь React «эти подписки независимы». Внешние сторы (useSyncExternalStore, Zustand) добавляют подписки на основе селекторов, чтобы обойти это; обычный контекст — нет, поэтому ты делишь вместо этого.
▸Частая ошибка
Противоположный отказ жирного контекста — provider hell: дюжина однополевых контекстов, вложенных в лестницу провайдеров в корне, плюс кастомный хук и файл-бойлерплейт на каждый срез. Ты сжал радиус поражения до нуля и заплатил за это церемонией — каждый новый кусок общего состояния теперь 40-строчный ритуал, а дерево нечитаемо. Дели по оси, которая важна (перерисовывает ли это лишнее на частоте, которую ты можешь измерить?), а не рефлекторно. Группируй поля, которые меняются вместе, в один контекст; отделяй состояние от dispatch только тогда, когда есть реальная популяция потребителей «только на запись». Если ты обнаруживаешь, что тебе нужны мелкогранулярные селекторы по многим срезам — это сигнал, что ты перерос обычный контекст: тянись к внешнему стору, а не к двадцатому провайдеру.
Ты разделил AppContext на AuthContext и ThemeContext, но каждый провайдер всё ещё передаёт value={{ ...fields }} как свежий объектный литерал. Переключение темы теперь перерисовывает и каждого потребителя AuthContext, хотя ни одно поле авторизации не изменилось. В чём причина?
Значение контекста — это его радиус поражения перерисовки: React уведомляет каждого потребителя useContext, когда значение провайдера меняет идентичность, независимо от того, какое поле они читают. Так что гранулярность управляет радиусом. Три приёма его сжимают: (1) дели по ответственности — один контекст на каждый независимо меняющийся срез, чтобы потребитель перерисовывался только ради того среза, который читает; (2) отделяй состояние от dispatch/сеттеров — компоненты «только на запись» подписываются на стабильный контекст сеттера и перерисовываются практически никогда; (3) стабилизируй значение через useMemo (или передавай голые значения / стабильные сеттеры), чтобы один перерендер провайдера сам по себе не сбивал идентичность и не сводил разделение на нет. Режим отказа — provider hell: переразбиение в лестницу однополевых провайдеров, размен радиуса поражения на церемонию и нечитаемый бойлерплейт. Дели по измеренному давлению: группируй то, что меняется вместе, отделяй состояние от dispatch только там, где реально есть потребители «только на запись», а когда тебе по-настоящему нужны мелкогранулярные селекторы по многим срезам — это сигнал перейти к внешнему стору, а не добавлять ещё один провайдер.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.