open atlas
↑ К треку
Паттерны React RXP · 06 · 02

Разделение контекстов

Гранулярность контекста задаёт радиус поражения перерисовки: один жирный контекст перерисовывает каждого потребителя на любое изменение. Дели по ответственности, отделяй state от сеттеров и стабилизируй значение через useMemo — не сползая в provider hell.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ты потянулся к контексту, чтобы убить проброс пропсов, и это сработало — user, theme, locale и дюжина сеттеров текут из одного AppContext в корне. Потом загорается профайлер: переключение тёмной темы перерисовывает корзину, панель чата перерисовывается, когда подгружается имя пользователя, а флип настройки перекрашивает всё приложение. Ничего не сломано. Всё просто перерисовывается на изменения, до которых ему нет дела.

Это не баг контекста. Это баг гранулярности. Форма твоего контекста — сколько ты напихал в один провайдер, отделяешь ли ты читаемое состояние от писателей — напрямую управляет тем, как далеко расходится одно изменение. Этот урок — про то, чтобы выбирать эту форму осознанно.

Цель

После этого урока ты можешь объяснить, почему значение контекста — это его радиус поражения перерисовки: каждый потребитель провайдера перерисовывается, когда значение этого провайдера меняет идентичность; разделить один жирный контекст на сфокусированные контексты по ответственности, чтобы потребитель перерисовывался только ради того среза, который читает; отделить состояние от его dispatch/сеттеров в два контекста, чтобы компоненты «только на запись» не перерисовывались на изменения состояния; стабилизировать каждое передаваемое значение через useMemo, чтобы оно не получало новую идентичность на каждом рендере; и распознавать режим отказа — переразбиение в башню вложенных провайдеров — и находить баланс.

1

Значение контекста — это его радиус поражения перерисовки: каждый потребитель перерисовывается, когда идентичность значения меняется. 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. Оба подписаны на один и тот же контекст, поэтому оба перерисовываются на любое из изменений. Радиус поражения — «все».

2

Дели по ответственности: один контекст на каждый независимо меняющийся срез. Состояние авторизации и состояние темы меняются с совершенно разной частотой и читаются разными частями дерева. Дай каждому свой контекст, и потребитель подпишется только на тот срез, который реально читает — и перерисуется только ради него.

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), не затронута, когда пользователь переключает тёмную тему. Радиус поражения сжался с «все» до «все, кто читает этот срез». Ось, по которой делить, — что меняется вместе, а что независимо, а не как данные нарисованы на доске.

3

Отделяй состояние от его сеттеров: писателям не нужно значение. Компоненту, который только меняет тему — кнопке-переключателю, — незачем перерисовываться, когда тема меняется. Но если 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 стабилен навсегда, поэтому потребители «только на запись» не стоят ничего.

4

Стабилизируй передаваемое значение через 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.