Подъём состояния и prop drilling

Интервьюер рисует на доске сайдбар с фильтром и список товаров: «Пользователь печатает — список фильтруется. Где живёт состояние фильтра?» За этим невинным вопросом прячется вся архитектура приложения.

Подъём состояния (lifting state up) — приём, при котором состояние переносят в ближайшего общего предка компонентов, которым оно нужно, а вниз отдают значение и колбэк для его изменения.

Слабый кандидат отвечает: «Состояние поиска — в компоненте поиска, там же useState». Звучит логично ровно до момента, когда список должен об этом состоянии узнать. Данные в React текут строго сверху вниз: соседний компонент не может «заглянуть» в состояние другого. Значит, состояние обязано жить выше — там, откуда видно обоих.

Разбор: два компонента, одна правда

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

function App() {
  // общий предок владеет состоянием — это единственный источник правды
  const [query, setQuery] = useState('');

  return (
    <div className="page">
      <SearchInput value={query} onChange={setQuery} />
      <ProductList query={query} />
    </div>
  );
}

// SearchInput ничего не хранит: он «глупый», им управляют сверху
function SearchInput({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

Важный нюанс, который стоит проговорить вслух: поднимать состояние нужно ровно настолько, насколько необходимо. Если вынести query в самый корень приложения «на всякий случай», каждое нажатие клавиши будет перерисовывать всё дерево. Состояние, поднятое слишком высоко, — такая же ошибка, как состояние, застрявшее слишком низко.

Prop drilling: когда предок далеко

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

function App() {
  const [theme, setTheme] = useState('dark');
  return <Layout theme={theme} setTheme={setTheme} />;
}

function Layout({ theme, setTheme }) {
  return <Sidebar theme={theme} setTheme={setTheme} />;   // просто передаёт дальше
}
function Sidebar({ theme, setTheme }) {
  return <Nav theme={theme} setTheme={setTheme} />;       // и этот тоже
}
function Nav({ theme, setTheme }) {
  return <NavItem theme={theme} setTheme={setTheme} />;   // и этот
}

function NavItem({ theme, setTheme }) {   // только здесь пропсы наконец нужны
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      Тема: {theme}
    </button>
  );
}

Чем это плохо на практике: три компонента из пяти «знают» про тему, хотя не должны. Добавили четвёртый пропс — правите сигнатуры всех промежуточных. Захотели переиспользовать Sidebar в другом месте — тащите за ним весь хвост пропсов. Компоненты перестают быть самостоятельными кирпичиками и превращаются в звенья одной трубы.

Сразу оговоритесь на собеседовании: prop drilling — это не смертный грех. Один-два уровня проброса читаются лучше, чем контекст, спрятанный где-то на другом конце проекта. Проблемой это становится примерно с третьего-четвёртого уровня и при частых изменениях сигнатур.

Context как решение и его цена

Context — это «телепорт» для данных: провайдер кладёт значение в дерево, любой потомок читает его напрямую через useContext, минуя промежуточные слои.

const ThemeContext = createContext(null);

function App() {
  const [theme, setTheme] = useState('dark');

  // без useMemo объект был бы новым на каждом рендере App
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      <Layout />   {/* промежуточные компоненты чисты: ни одного лишнего пропса */}
    </ThemeContext.Provider>
  );
}

function NavItem() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      Тема: {theme}
    </button>
  );
}

А теперь то, ради чего вопрос вообще задают. Цена контекста — ререндер всех потребителей. Когда значение провайдера меняется, React перерисовывает каждый компонент, который читает этот контекст. Не только тот, кто использует изменившееся поле, — все.

И здесь же самая частая ошибка в реальном коде:

// ПЛОХО: литерал объекта — новая ссылка на каждом рендере App.
// Даже если user и cart не менялись, все потребители перерисуются.
<AppContext.Provider value={{ user, cart, setCart }}>

// ХОРОШО: ссылка стабильна, пока не изменились зависимости
const value = useMemo(() => ({ user, cart, setCart }), [user, cart]);
<AppContext.Provider value={value}>

Второй приём, за который на собеседовании ставят плюс, — разделение контекстов. Данные меняются часто, функции-обновляторы — почти никогда. Разнесите их по двум контекстам, и компоненты, которым нужен только dispatch, перестанут перерисовываться при каждом изменении данных.

const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);   // dispatch стабилен между рендерами

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialCart);

  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

// кнопка «добавить в корзину» читает только dispatch —
// и не перерисовывается, когда меняется содержимое корзины
function AddButton({ id }) {
  const dispatch = useContext(CartDispatchContext);
  return <button onClick={() => dispatch({ type: 'add', id })}>В корзину</button>;
}

Как это работает под капотом

Провайдер сравнивает старое и новое значение через Object.is — ту же семантику, что и === для ссылок. Если значение изменилось, React обходит поддерево и помечает к обновлению каждый fiber-узел, подписанный на этот контекст. Подписка идёт по отдельному каналу, не через пропсы.

Отсюда следствие, которое любят спрашивать: React.memo не спасает потребителя контекста. memo сравнивает пропсы; если пропсы не изменились, ререндер от родителя действительно останавливается. Но обновление, пришедшее по каналу контекста, идёт мимо этой проверки — компонент перерисуется всё равно. Единственное, что реально помогает: не класть в контекст то, что часто меняется, дробить контексты и мемоизировать value.

Куда что класть

ДанныеГде хранить
Открыт ли дропдаун, текст в полеuseState внутри самого компонента
Фильтр, который нужен двум соседямПодъём в ближайшего общего предка
Тема, локаль, текущий пользовательContext: меняется редко, нужен всем
Данные с сервера (списки, профили)Библиотека запросов (TanStack Query, RTK Query), а не Context
Что открыто на экране, фильтры в URLРоутер и query-параметры — состояние, которым можно поделиться ссылкой

Частые ошибки на собеседовании

  • «Prop drilling — это плохо, всегда берём Context». Нет. Два уровня проброса честнее и читаемее, чем контекст ради контекста.
  • «Context — это стейт-менеджер». Context — это только механизм доставки значения по дереву. Ни кеша, ни селекторов, ни точечных подписок в нём нет.
  • Не могут назвать цену. Если кандидат говорит про Context и ни слова про ререндер потребителей и мемоизацию value — тема не понята.
  • Один «богоподобный» контекст на всё приложение. Тогда изменение любого поля перерисовывает половину экрана.
  • Вера в React.memo вокруг потребителя контекста. Он не блокирует обновление, пришедшее из контекста.

Итоги-шпаргалка

  • Состояние живёт в ближайшем общем предке тех, кому оно нужно, — не выше и не ниже.
  • Prop drilling терпим на 1–2 уровнях; боль начинается, когда промежуточные компоненты становятся «трубой» для чужих пропсов.
  • Context убирает проброс, но платит ререндером всех потребителей при смене значения.
  • value провайдера мемоизируйте: литерал объекта в JSX — новая ссылка каждый рендер.
  • Дробите контексты: отдельно данные, отдельно dispatch; в контекст кладите то, что меняется редко.
  • Серверные данные — не в Context, а в библиотеку запросов с кешем.
Проверьте себя
1. Компонент читает значение через useContext и обёрнут в React.memo. Провайдер отдал новое значение, но пропсы компонента не изменились. Что произойдёт?
AНичего: React.memo сравнит пропсы, они те же, ререндера не будет
BКомпонент перерисуется: обновление приходит по каналу контекста и React.memo его не блокирует
CПерерисуется, только если значение контекста — примитив, а не объект
DReact выведет предупреждение о лишнем ререндере и пропустит обновление
2. Почему value={{ theme, setTheme }} прямо в JSX провайдера — плохая практика?
AReact не разрешает передавать объекты в value, только примитивы
BНа каждом рендере провайдера создаётся новый объект, поэтому все потребители перерисовываются, даже если theme не менялась
CТакое значение нельзя прочитать через useContext, нужен useReducer
DИз-за этого теряется типизация значения контекста