Skip to main content

Що таке prop drilling і як його уникнути

Prop drilling - це коли дані передаються через кілька шарів компонентів як пропси, хоча проміжні компоненти їх не використовують.

Теорія

TL;DR

  • Аналогія: передаєш записку через п'ятьох людей, щоб дістатися до одного в кінці. Всі посередині тримають, але не читають.
  • Головна проблема: проміжні компоненти засмічуються пропсами, які їм не потрібні. Рефакторинг стає болючим.
  • Правило вибору: 1-2 рівні вглибину - нормально. Від 3 і більше - варто дивитись на Context або стейт-менеджер.
  • Context пропускає посередників повністю. Стейт-менеджери (Redux, Zustand) - для бізнес-логіки і складного стану.
  • Drilling створює зв'язність. Додав новий проп - оновлюй весь ланцюг.

Швидкий приклад

tsx
// 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 DrillingReact ContextRedux/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

tsx
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 бере його напряму.

Типові помилки

Забути пробросити новий проп під час рефакторингу

tsx
// Додали newFeature в App, забули шлях до Child <Parent user={user} newFeature={true} /> function Parent({ user }) { return <Child user={user} />; // newFeature мовчки губиться }

Child отримує undefined. Це класичний баг при глибокому drilling. З Context споживачі самі оголошують що їм потрібно, тому додавання нового значення до провайдера не потребує змін у проміжних компонентах.

Один Context на весь додаток зверху

tsx
<UserContext.Provider value={user}> <EntireApp /> </UserContext.Provider>

Кожен useContext(UserContext) у споживача спрацює при зміні user. В додатку з десятками споживачів це накопичується. Розташовуй провайдери ближче до того місця, де дані реально потрібні.

Відсутній defaultValue у createContext

tsx
const Ctx = createContext(); // без значення за замовчуванням function Consumer() { const val = useContext(Ctx); // падає поза Provider }

Якщо Consumer рендериться поза Provider (в тестах, порталах, lazy-маршрутах) - runtime помилка. Виправлення: createContext({}) або createContext(null) з перевіркою.

Об'єкт як value без мемоізації

tsx
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

tsx
// Проблема: 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 для кошика

tsx
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

tsx
// Тригерить перерендер ВСІХ споживачів при кожному рендері 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 перестає оновлюватись на зайвих рендерах.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?