Що таке prop drilling і як його уникнути
Prop drilling - це коли дані передаються через кілька шарів компонентів як пропси, хоча проміжні компоненти їх не використовують.
Теорія
TL;DR
- Аналогія: передаєш записку через п'ятьох людей, щоб дістатися до одного в кінці. Всі посередині тримають, але не читають.
- Головна проблема: проміжні компоненти засмічуються пропсами, які їм не потрібні. Рефакторинг стає болючим.
- Правило вибору: 1-2 рівні вглибину - нормально. Від 3 і більше - варто дивитись на Context або стейт-менеджер.
- Context пропускає посередників повністю. Стейт-менеджери (Redux, Zustand) - для бізнес-логіки і складного стану.
- Drilling створює зв'язність. Додав новий проп - оновлюй весь ланцюг.
Швидкий приклад
// Prop drilling: user проходить через Parent, якому він не потрібен
function App() {
const user = { name: 'John' };
return <Parent user={user} />; // передаємо вниз
}
function Parent({ user }) { // отримує, але не використовує
return <Child user={user} />; // вимушений пробрасувати
}
function Child({ user }) {
return <p>Привіт, {user.name}!</p>; // нарешті використовує
}Parent не має жодної причини знати про user. Але знає - бо Child потребує. Це і є prop drilling.
Чому це стає проблемою
На 2 рівнях нікого не хвилює. На 4-5 рівнях перейменування пропа означає правки в кожному компоненті ланцюга. Додаєш новий feature flag, який потрібен GrandChild - і тепер оновлюєш App, Layout, Sidebar, Panel просто щоб протягнути один булевий.
Реальна ціна - це зв'язність. Компоненти залежать структурно від пропсів, які навіть не використовують. Тестуєш один - доводиться мокати пропси, які йому байдужі.
Коли що використовувати
- 1-2 рівні вглибину: залишай пропси, явний потік даних - це перевага
- Тема, локаль, дані авторизації в 3+ компонентах: Context API
- Складний стан з async-діями або збереженням: Redux Toolkit або Zustand
- Часті оновлення для багатьох споживачів: стейт-менеджер замість Context, щоб уникнути зайвих перерендерів
Таблиця порівняння
| Аспект | Prop Drilling | React Context | Redux/Zustand |
|---|---|---|---|
| Потік даних | Ручний на кожному рівні | Provider огортає піддерево | Глобальний store + селектори |
| Перерендери | Тільки прямі нащадки | Всі споживачі при зміні value | Контрольовані через useSelector |
| Бойлерплейт | Немає спочатку, росте швидко | Provider + useContext | Дії/редюсери (простіше в RTK) |
| Тестування | Мокати пропси просто | Потрібна обгортка Context | Ізоляція store |
| Підходить для | Плоскі дерева (<3 рівні) | UI-стан, середні додатки | Великі додатки, async-логіка |
Як React обробляє це всередині
React будує дерево fiber зверху вниз. Prop drilling додає зайві дані до проміжних fiber-вузлів - кожен рівень ланцюга несе їх під час reconciliation. Context (контекст) працює інакше: він використовує спеціальний Context fiber, який кешує поточне значення і сповіщає лише споживачів через окрему чергу dispatcher. Компоненти, які не викликають useContext, пропускають оновлення повністю.
Рефакторинг через Context
const CartContext = createContext();
function Dashboard() {
const [cartTotal, setCartTotal] = useState(0);
return (
<CartContext.Provider value={{ cartTotal, setCartTotal }}>
<Header />
</CartContext.Provider>
);
}
function Header() {
return <Sidebar />; // жодного пропа cartTotal
}
function Sidebar() {
return <CartWidget />; // жодного пропа cartTotal
}
function CartWidget() {
const { cartTotal } = useContext(CartContext);
return <span>{cartTotal} товарів</span>;
}Header і Sidebar чисті. Вони не знають про існування cartTotal. CartWidget бере його напряму.
Типові помилки
Забути пробросити новий проп під час рефакторингу
// Додали newFeature в App, забули шлях до Child
<Parent user={user} newFeature={true} />
function Parent({ user }) {
return <Child user={user} />; // newFeature мовчки губиться
}Child отримує undefined. Це класичний баг при глибокому drilling. З Context споживачі самі оголошують що їм потрібно, тому додавання нового значення до провайдера не потребує змін у проміжних компонентах.
Один Context на весь додаток зверху
<UserContext.Provider value={user}>
<EntireApp />
</UserContext.Provider>Кожен useContext(UserContext) у споживача спрацює при зміні user. В додатку з десятками споживачів це накопичується. Розташовуй провайдери ближче до того місця, де дані реально потрібні.
Відсутній defaultValue у createContext
const Ctx = createContext(); // без значення за замовчуванням
function Consumer() {
const val = useContext(Ctx); // падає поза Provider
}Якщо Consumer рендериться поза Provider (в тестах, порталах, lazy-маршрутах) - runtime помилка. Виправлення: createContext({}) або createContext(null) з перевіркою.
Об'єкт як value без мемоізації
function App() {
const [theme, setTheme] = useState('light');
return (
// новий об'єкт при кожному рендері = всі споживачі оновлюються
<ThemeContext.Provider value={{ theme, setTheme }}>
<Button />
<UnrelatedChart /> {/* оновлюється при кожному рендері App */}
</ThemeContext.Provider>
);
}Новий reference при кожному рендері тригерить усіх споживачів. Огорни в useMemo: const value = useMemo(() => ({ theme, setTheme }), [theme]). За моїм досвідом, саме це найчастіше спливає на code review: хтось замінив drilling на Context, але продуктивність погіршала через немемоізований об'єкт у Provider.
Де зустрічається в реальних проектах
- Chakra UI:
<ChakraProvider theme={customTheme}>передає тему глобально, жодних пропсів теми не потрібно - Material-UI:
<ThemeProvider>для стилізації по всьому додатку, включно з модалами і дровер-панелями - Next.js app router: стан авторизації в Context Provider, який огортає layout-компоненти
- Великі продакшн-додатки: Redux для бізнес-стану, Context для UI-теми і локалізації
Питання на співбесіді
Q: Покажи prop drilling в дереві App -> Layout -> Sidebar -> UserMenu, потім виправ через Context.
A: Пробрасуємо user через кожен рівень як проп. Потім створюємо UserContext, огортаємо Layout в UserContext.Provider, і викликаємо useContext(UserContext) напряму в UserMenu. Layout і Sidebar стають чистими.
Q: Коли Context викликає більше перерендерів ніж prop drilling?
A: Коли value Provider - це новий об'єкт або масив при кожному рендері без useMemo. Всі споживачі проходять reconciliation навіть якщо дані логічно не змінились. Drilling оновлює лише прямих нащадків.
Q: Context проти Redux для кошика покупок?
A: Context підходить для простого кошика (локальний UI-стан). Redux варто розглянути коли потрібні збереження, синхронізація з сервером або складні дії на зразок застосування купонів з async-валідацією.
Q: Як тестувати компонент з Context без drilling?
A: Огортаємо компонент у Provider всередині тесту: render(<CartContext.Provider value={mockCart}><CartWidget /></CartContext.Provider>).
Q: Як concurrent mode в React 18+ впливає на prop drilling порівняно з Context?
A: Drilling може блокувати suspense-межі, бо дані недоступні поки батько не відрендериться. Context з useTransition дозволяє React позначати оновлення як несрочні, тому UI залишається чуйним під час повільних рендерів.
Приклади
Базовий: вітання користувача без Context і з Context
// Проблема: user пробрасується через Layout, якому він не потрібен
function App() {
const user = { name: 'Alice', role: 'admin' };
return <Layout user={user} />;
}
function Layout({ user }) { // не використовує user
return <Navbar user={user} />;
}
function Navbar({ user }) {
return <span>Вітаємо, {user.name}</span>;
}
// Виправлення: Context
const UserContext = createContext(null);
function App() {
const user = { name: 'Alice', role: 'admin' };
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
function Layout() {
return <Navbar />; // жодного пропа user
}
function Navbar() {
const user = useContext(UserContext);
return <span>Вітаємо, {user.name}</span>;
}Layout повністю відокремлений від даних користувача. Додавання role або email не потребує жодних змін у Layout.
Середній: дашборд e-commerce з Context для кошика
const CartContext = createContext();
function Dashboard() {
const [cartTotal, setCartTotal] = useState(3);
return (
<CartContext.Provider value={{ cartTotal, setCartTotal }}>
<Header />
</CartContext.Provider>
);
}
function Header() {
return (
<nav>
<Logo />
<Sidebar />
</nav>
);
}
function Sidebar() {
return <CartWidget />;
}
function CartWidget() {
const { cartTotal } = useContext(CartContext);
return <button>Кошик ({cartTotal})</button>;
}Додавання нового споживача кошика (наприклад, підсумок на сторінці оформлення) - просто виклик useContext(CartContext). Жодних змін у пропсах нікуди.
Просунутий: пастка перерендерів і виправлення через useMemo
// Тригерить перерендер ВСІХ споживачів при кожному рендері App
const ThemeContext = createContext();
function App() {
const [theme, setTheme] = useState('light');
return (
// новий об'єкт при кожному рендері = всі споживачі оновлюються
<ThemeContext.Provider value={{ theme, setTheme }}>
<Button />
<ExpensiveChart /> {/* оновлюється навіть при незв'язаних змінах стану */}
</ThemeContext.Provider>
);
}
// Виправлення: мемоізуємо value
function App() {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Button />
<ExpensiveChart /> {/* тепер оновлюється тільки при зміні theme */}
</ThemeContext.Provider>
);
}setTheme стабільний (той самий reference від useState), тому useMemo з [theme] створює новий об'єкт тільки при реальній зміні теми. ExpensiveChart перестає оновлюватись на зайвих рендерах.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.