Подъём состояния и 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, а в библиотеку запросов с кешем.