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

Общее состояние через контекст компонента

Compound-компоненты делят состояние через контекст, приватный для модуля компонента: дочерние читают активное значение без проброса пропсов, а потребитель его не видит. Режим отказа — утечка контекста в приложение или нестабильный объект значения.

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

Compound-компонент позволяет потребителю написать <Tabs><Tab/><Tab/><Panel/></Tabs> — плоскую, читаемую разметку, где дети выглядят независимыми. Но это не так: активная вкладка должна дойти до каждого Tab и каждого Panel, чтобы те знали, подсветиться им или показаться. Очевидный путь — пробросить activeId и setActiveId вниз пропсами каждому ребёнку. Это работает на один уровень и рушится в тот момент, когда потребитель вкладывает Tab в <div> или в собственную обёртку, — пропсы до него не дотянутся.

Механизм, который делает compound-компоненты по-настоящему эргономичными, — это контекст, созданный внутри собственного модуля компонента: приватный для него, невидимый потребителю, — который каждый sub-компонент читает, чтобы найти активное значение. Этот урок — про тот самый контекст: как ограничить его область, почему он остаётся внутренним и два способа, которыми он ломается.

Цель

После этого урока ты можешь собрать compound-компонент (Tabs), sub-компоненты которого делят состояние через приватный для модуля контекст вместо пропсов; объяснить, почему этот контекст создан внутри модуля и никогда не экспортируется на всё приложение; и назвать два режима отказа паттерна — утечку контекста, когда несвязанный код может его читать или предоставлять, и нестабильный value провайдера, который перерисовывает каждую панель на каждое переключение вкладки, потому что изменилась идентичность значения контекста.

1

Проброс пропсов ломает compound-компоненты: детей можно вложить произвольно, поэтому пропсы до них не дотянутся. Разметку составляет потребитель, а значит ты не контролируешь, где в дереве сидят Tab и Panel. Любой подход, продевающий activeId через пропсы, предполагает фиксированную форму, которой у тебя нет.

// что пишет потребитель — обрати внимание на обёртку div, которую поставил не ты
<Tabs defaultId="a">
  <div className="tab-row">
    <Tab id="a">Account</Tab>
    <Tab id="b">Billing</Tab>
  </div>
  <Panel id="a">…</Panel>
  <Panel id="b">…</Panel>
</Tabs>

Tabs не может передать проп в <Tab id="a"> — между ними <div>, который Tabs не рендерит. Пропсы идут на один уровень; активное состояние должно дойти до произвольной глубины.

2

Создай контекст внутри модуля компонента — это приватный канал, который делят sub-компоненты. Вызов createContext живёт рядом с компонентом, а не в каком-то общеприложенческом файле провайдеров. Ничто извне этого модуля его не импортирует. В этой приватности весь смысл: потребитель составляет Tabs и никогда не знает, что контекст существует.

"use client"; // контекст + состояние, поэтому это поддерево — Client Component
import { createContext, useContext, useId, useState, useMemo } from "react";

interface TabsCtx { activeId: string; setActiveId: (id: string) => void; }
// не экспортируется — приватен для этого модуля
const TabsContext = createContext<TabsCtx | null>(null);

// один внутренний хук, который вызывают sub-компоненты; он же требует "должно быть внутри <Tabs>"
function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tab/Panel must be used inside <Tabs>");
  return ctx;
}

TabsContext и useTabs локальны для файла. Ошибка в useTabs превращает неверное использование (<Tab> отрендерен вне <Tabs>) в понятное сообщение вместо запутывающего null.

3

Родитель владеет состоянием и предоставляет его; sub-компоненты читают активное значение без переданных им пропсов. Tabs держит activeId в useState, оборачивает свои children в провайдер, и каждый Tab/Panel вызывает useTabs(), чтобы получить текущее значение, — независимо от того, насколько глубоко он в разметке потребителя.

export function Tabs({ children, defaultId }: { children: React.ReactNode; defaultId: string }) {
  const [activeId, setActiveId] = useState(defaultId);
  const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);
  return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}

export function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeId, setActiveId } = useTabs();
  return (
    <button role="tab" aria-selected={activeId === id} onClick={() => setActiveId(id)}>
      {children}
    </button>
  );
}

export function Panel({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeId } = useTabs();
  return activeId === id ? <div role="tabpanel">{children}</div> : null;
}

В JSX потребителя не появляется ни одного пропа activeId. Состояние течёт через контекст, поэтому разметка остаётся плоской, а дети — перемещаемыми.

4

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

Утечка: если ты экспортируешь TabsContext и предоставляешь его из верхнеуровневого провайдера приложения, чтобы «что угодно могло читать активную вкладку», ты превратил деталь, ограниченную компонентом, в глобальное состояние. Теперь два Tabs на одной странице делят один активный id, несвязанный код сцепляется с твоими внутренностями, а инкапсуляция, сделавшая паттерн эргономичным, исчезла.

Идентичность: потребители контекста перерисовываются всякий раз, когда value провайдера — это новая ссылка, а не когда меняются данные внутри. Свежий объектный литерал на каждый рендер — всегда новая ссылка.

// БАГ: новый объект на каждый рендер → каждый Tab и Panel перерисовывается на ЛЮБОЙ рендер родителя,
// даже на рендеры, не менявшие activeId
return <TabsContext.Provider value={{ activeId, setActiveId }}>{children}</TabsContext.Provider>;

// ИСПРАВЛЕНИЕ: мемоизируй, чтобы ссылка была стабильной, пока activeId реально не изменится
const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);

setActiveId из useState уже стабилен, поэтому единственная реальная зависимость — activeId. С useMemo переключение вкладок перерисовывает только то, что зависит от изменения, а не всё поддерево на каждый несвязанный рендер.

Разбор примера

Проброс пропсов против приватного контекста — тот же Tabs, ставший перемещаемым. Начни с версии на пробросе пропсов, которая работает, только когда дети — прямые упорядоченные сиблинги:

// ДО: Tabs клонирует детей, чтобы внедрить activeId — хрупко и с утечкой
function Tabs({ children, defaultId }: { children: React.ReactElement[]; defaultId: string }) {
  const [activeId, setActiveId] = useState(defaultId);
  return (
    <>
      {React.Children.map(children, (child) =>
        // предполагает, что каждый ребёнок принимает activeId/onSelect и является *прямым* ребёнком
        React.cloneElement(child, { activeId, onSelect: setActiveId }),
      )}
    </>
  );
}
// ломается в тот же миг, когда потребитель оборачивает Tab в <div> — cloneElement достаёт только прямых детей

cloneElement заставляет каждого ребёнка принимать внедряемые пропсы и достаёт ровно один уровень — оберни Tab в раскладочный div, и он перестанет получать activeId. Версия на контексте снимает это ограничение:

// ПОСЛЕ: состояние делится через приватный для модуля контекст; дети могут вкладываться где угодно
const TabsContext = createContext<TabsCtx | null>(null); // не экспортируется

function Tabs({ children, defaultId }: { children: React.ReactNode; defaultId: string }) {
  const [activeId, setActiveId] = useState(defaultId);
  const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);
  return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}
// Tab и Panel вызывают useTabs() — независимо от глубины, без внедряемых пропсов, со стабильной идентичностью значения

Версия «после» строго мощнее и проще на месте вызова: дети — обычный ReactNode, а не ограниченный ReactElement[], и потребитель может вкладывать их свободно. Уплаченная цена — контекст и useMemo — это ровно та цена, что покупает независимость от глубины и ограниченные перерисовки. Этот размен оправдан именно потому, что compound-компоненты существуют, чтобы дать потребителю контроль над разметкой.

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

Почему держать контекст приватным, а не экспортировать его как «API» компонента? Потому что контекст — это деталь реализации того, как sub-компоненты переговариваются друг с другом, а не контракт с потребителем. Публичный API — это <Tabs>, <Tab>, <Panel>. Если ты экспортируешь TabsContext, потребители будут читать его напрямую, и теперь ты не можешь поменять форму значения (переименовать activeId, добавить registerTab, перейти на reducer) без ломающего изменения. Сохранение его приватным для модуля означает, что весь механизм координации волен эволюционировать, пока JSX-API остаётся стабильным, — по той же причине инкапсуляции, по которой ты держал бы поле класса приватным.

Частая ошибка

Тонкий баг с перерисовкой — это не «я забыл useMemo» в абстракции, а то, что контекст сравнивает value по ссылке, а не по содержимому. Разработчики рассуждают «activeId не изменился, значит ничего не перерисуется» — и ошибаются: новый объектный литерал { activeId, setActiveId } — это новая ссылка на каждый рендер, поэтому каждый потребитель перерисовывается на каждый рендер родителя, включая вызванные несвязанным состоянием в другом месте Tabs. На маленьком Tabs ты этого не заметишь. На <Tabs>, панели которого держат каждое дорогое поддерево, нестабильное значение превращает один несвязанный рендер в полную перерисовку каждой панели. Мемоизируй значение (или раздели статику и динамику на два контекста), чтобы идентичность менялась только когда меняются данные.

Проверь себя
Викторина

В compound-компоненте <Tabs> провайдер записан как value={{ activeId, setActiveId }} (свежий объектный литерал на каждый рендер). activeId в этот рендер не менялся, но Tabs перерисовался по несвязанной причине. Что произойдёт с потребителями Tab и Panel и почему?

Итог

Compound-компоненты делят состояние через контекст, созданный внутри собственного модуля компонента, — приватный, неэкспортируемый, невидимый потребителю. Родитель (Tabs) владеет состоянием, оборачивает children в провайдер, и каждый sub-компонент (Tab, Panel) читает активное значение внутренним хуком useTabs(), так что активное состояние доходит до детей на любой глубине вложенности без проброса пропсов или cloneElement. Этот ограниченный компонентом контекст — механизм, делающий паттерн эргономичным, а сохранение его внутренним позволяет координации эволюционировать за стабильным JSX-API. Два режима отказа очерчивают границы: утечка контекста на всё приложение превращает приватную деталь в глобальное состояние и ломает инкапсуляцию; нестабильный value перерисовывает каждого потребителя на каждый рендер родителя, потому что контекст сравнивает значение по ссылке, а не по содержимому, — так что мемоизируй значение (или раздели его), и переключение вкладки перерисует только то, чего касается изменение.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.