Проп-дрілінг — це не причина для встановлення Redux чи Zustand
Перевірте чотири поширені причини додавання державної бібліотеки до функціонального коду React: використання Context для передачі значень через ієрархію компонентів, useState, useSyncExternalStore та витрати на повторне відрендерування через Context.
Запитайте команду, чому у їхньому React-додатку використовується Redux, Zustand чи MobX — зазвичай першою відповіддю буде проблема передачі даних через компоненти. Це справді дратує, але це не є вагомою причиною для додавання залежностей, так само як і три пояснення, що зазвичай слідують за цим. Нижче кожна з цих чотирьох причин перевіряється на прикладі невеликого функціонального коду, разом із тим, що React вже надає для таких ситуацій, та ситуаціями, коли бібліотека для керування станом справді окуповує себе.
Чотири поширені пояснення
Аргументи зазвичай надходять у передбачуваному порядку:
- Передача значення через компоненти, які його не використовують, є складною, тому додатку потрібен магазин даних.
- Клієнтський стан, який дійсно змінюється, наприклад прапорець світлого чи темного режиму, безумовно потребує бібліотеки.
- Щоб кожен інший компонент, який читає цей стан, оновлювався автоматично, без ручної налаштування параметрів, необхідна така бібліотека.
Кожен з цих підходів виявляється неефективним, як тільки починаєш дивитися на конкретний код.
Prop drilling: проблема, яку React вирішив багато років тому
Prop drilling означає передачу значення крізь кілька рівнів виключно для того, щоб глибоко вкладений компонент міг його прочитати, при цьому жоден з проміжних рівнів його не використовує. Чотири невеликі файли ілюструють цей механізм. App зберігає об’єкт user у стані та передає його до Dashboard.
// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return <Dashboard user={user} />;
}
export default App;
Dashboard нічого не робить з user, окрім як передати його до Sidebar.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard({ user }) {
return <Sidebar user={user} />;
}
export default Dashboard;
Sidebar робить те саме, розпаковуючи два поля для Avatar.
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar({ user }) {
return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;
Лише Avatar насправді відображає дані.
// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
return <img src={avatarUrl} alt={name} />;
}
export default Avatar;
Ні Dashboard.jsx, ні Sidebar.jsx не використовують дані user у власній логіці. Вони є лише посередниками. Якщо avatarUrl буде перейменовано на photoUrl, обидва файли потребуватимуть змін, навіть якщо їхня поведінка не зміниться, просто тому, що вони передають дані, які належать не їм.
Контекст безпосередньо передає значення
Вбудованим рішенням у React є Context. Спочатку об’єкт контексту створюється один раз у власному модулі.
// context/UserContext.js
import { createContext } from 'react';
const UserContext = createContext(null);
export default UserContext;
Потім App обгортає піддрощу об’єктом Provider контексту та передає user як його значення, замість того щоб передавати його як проп.
// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
export default App;
Dashboard та Sidebar перетворюються на суто форматні елементи, у яких зовсім не згадується user.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard() {
return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar() {
return <Avatar />;
}
export default Sidebar;
Нарешті, Avatar самостійно отримує це значення.
// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function Avatar() {
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;
Кожен з цих трьох елементів виконує одну функцію. createContext створює канал, який не залежить від пропсів. Provider одночасно робить user доступним для всього свого піддрева. useContext(UserContext) читає значення з найближчого відповідного Provider, розташованого над компонентом, ігноруючи всі проміжні рівні. Проміжні компоненти перестають згадувати user, оскільки вони ніколи його не потребували. До речі, у React 19 також можна безпосередньо відображати об’єкт контексту як provider, але формат .Provider, показаний тут, все ще працює.
Чому аргумент про „дрілінг пропсів“ залишився попри втрату своєї причини
Історія пояснює, чому цей дискурс триває. Redux з’явився у червні 2015 року, його створили Ден Абрамов та Ендрю Кларк, і він базується на архітектурі Flux від Facebook: єдиний центральний сховище з суворими правилами щодо того, як може змінюватися стан. API Context у React став стабільною, офіційно підтримуваною функцією лише у версії 16.3 у березні 2018 року, майже через три роки. На початковому етапі розвитку Redux сховище дійсно було практичним способом зробити значення доступним у будь-якій частині структури, не передаючи його через кожен компонент.
Це перестало бути правдою, коли Context став стабільним, але навчання так і не встигло змінитися. У навчальних матеріалах продовжували використовувати проблему передачі значень через пропи як мотивацію, оскільки її легко проілюструвати на дошці, і ця звичка залишилась навіть після того, як причина її виникнення зникла.
Проте буріння свердлом стосується розподілу, а не змін. Наступне питання: що відбувається, коли розподілений значення починає змінюватися на клієнті.
Зміна стану — саме те, що робить useState
Візьмемо перемикач теми: один флаг, який містить light або dark, який змінюється за допомогою кнопки, розташованої безпосередньо поруч із текстом, що його відображає.
// components/SettingsPanel.jsx
import { useState } from 'react';
function SettingsPanel() {
const [theme, setTheme] = useState('light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default SettingsPanel;
Клацання на кнопці викликає setTheme, SettingsPanel переробляється з новим значенням, і абзац оновлюється. Жодних бібліотек не потрібно, і вони не використовуються. Керування значенням, його зміна та переробка елемента — саме для цього існує useState, і кожен React-додаток робить це постійно.
Зазвичай приклад подається з певною модифікацією, яка виконує всю справжню роботу. Проблема не в тому, що значення змінюється; проблема в тому, що значення читається з іншого місця. Уявіть собі, що кнопка залишається всередині SettingsPanel.jsx, тоді як Header.jsx та Sidebar.jsx» — два незалежні файли, які нічим не імпортуються один одним та не імпортуються SettingsPanel, — також мають відображати поточну тему. Стан, створений за допомогою useState, належить до окремої інстанції компонента. Ніщо ззовні цього компонента не може його прочитати чи отримати сповіщення про зміни, якщо тільки спільний предок не передасть його далі, що знову призводить до проблеми передачі значень через пропси.
Отже, useState чудово справляється з обробкою змін. Відкритим питанням залишається те, як два компоненти без спільного батька можуть автоматично оновлюватися без використання пропсів.
Обмін станом між незалежними компонентами за допомогою useSyncExternalStore
Якщо ні Header, ні Sidebar не можуть володіти цим значенням, воно мусить знаходитися поза деревом компонентів: у змінні рівня модуля, яка ініціалізується лише один раз під час першого імпорту її файлу, а не всередині якоїсь функції компонента. React ніколи не бачить простої змінної, якій змінюють значення, тому не може самостійно переробити відображення щось, коли це значення змінюється. Невеликий модуль-сховище заповнює цю прогалину.
// store/themeStore.js
import { useSyncExternalStore } from 'react';
let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
state = { ...state, theme };
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
return useSyncExternalStore(subscribe, () => state.theme);
}
Модуль містить об’єкт state та множину Set слухачів. Функція setTheme замінює state на новий об’єкт та викликає кожного слухача. Приватна функція реєстрації додає слухача до цієї множини та повертає функцію для очищення, яка його видаляє. Функція useTheme поєднує все це з React за допомогою useSyncExternalStore — хука, створеного спеціально для стану, який знаходиться поза компонентами та використовується кількома з них.
Цей хук приймає два аргументи. Перший — функція реєстрації, яка пояснює React, як підключити функцію-відповідь, яка має виконуватися щоразу, коли змінюється зовнішня значення, і має повертати відповідну функцію для відмови від підписки з метою очищення. Другий аргумент — getSnapshot, у цьому випадку функція-стрілка () => state.theme — це спосіб, яким React отримує поточне значення, коли це необхідно.
Два користувачі імпортують цей хук, і жоден з них не знає про іншого.
// components/Header.jsx
import { useTheme } from '../store/themeStore';
function Header() {
const theme = useTheme();
return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';
function Sidebar() {
const theme = useTheme();
return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;
Третій компонент містить кнопку та імпортує як хук, так і setTheme з того самого магазину.
// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';
function ThemeToggleButton() {
const theme = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
export default ThemeToggleButton;
Що відбувається крок за кроком
Кожен з наведених нижче кроків можна спостерігати під час виконання коду:
- Під час ініціалізації
HeaderтаSidebarкожен з них викликаєuseTheme(). React викликаєgetSnapshotдля обох, отримує значення'light'та відображає їх з цим значенням. - React також викликає функцію реєстрації по одному разу для кожного компонента, тож у спільний набір
listenersдодаються два внутрішні керувальні функції React. Компоненти залишаються окремими елементами в цьому наборі.
ThemeToggleButton викликає setTheme('dark'). Спочатку змінна state присвоюється абсолютно новому об’єкту { theme: 'dark' }; це звичайний JavaScript без жодних особливостей React.setTheme проходить по множині та викликає кожного слухача. Оскільки ці слухачі належать до React, їхній виклик змушує React перевірити кожен компонент, який був зареєстрований.getSnapshot для компонента Header, бачить значення 'dark' замість 'light' та перерисовує його. Та сама перевірка призводить до перерисовування компонента Sidebar.Цей setTheme — це не той сеттер, який повертає useState. Це звичайний вручну написаний код, який виконує дві функції за один виклик: оновлює джерело істини, а потім сповіщає всіх слухачів.
Жодних параметрів не було передано нікуди. Вся схема підключень складається з імпортів. Приблизно так само працює Zustand всередині: це невелика, упакована версія того ж паттерну реєстрації та сповіщення.
Важливі моменти, які варто знати
getSnapshotмає повертати ту саму значення, коли нічого не змінилося. Безпечно повертати примітивну величину, наприкладstate.theme; створення нового об’єкта чи масиву при кожному виклику змушує React вважати, що сторідж постійно змінюється.- Якщо ви рендерите на сервері,
useSyncExternalStoreприймає третій аргумент —getServerSnapshot— для початкового HTML. Стан на рівні модуля на сервері також ділиться між запитами, тому не включайте до нього дані окремих користувачів.
Чому Context не є безпечним компромісом
У разі використання магазину модулів немає нічого, що потрібно було б надати. Змінна state у файлі themeStore.js ніколи не знаходилась у дереві компонентів; до неї компоненти отримують доступ через виклик хука, без участі предка типу Provider. Огортання App у ThemeProvider додало б ще один шар, який нічого не передає.
Контекст має свої законні сфери застосування: обмеження діапазону дії значення до певної піддеревини чи заміна залежностей у тестах шляхом відображення іншого Provider. Проте його часто рекомендують як обережну альтернативу бібліотеці, і в цій ролі його вартість перевищує те, що здається на перший погляд. Розгляньте можливість використання одного контексту для зберігання як теми, так і кошика.
// context/AppContext.jsx
import { createContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children }) {
const [state, setState] = useState({
theme: 'light',
cart: ['book'],
});
return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;
Невеликий етікеток виводить лише інформацію про тему з цього контексту.
// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';
function ThemeLabel() {
const { theme } = useContext(AppContext);
return <span>{theme}</span>;
}
export default ThemeLabel;
useContext(AppContext) підписує ThemeLabel на весь об’єкт, який тримає Provider, а не лише на theme. Коли cart змінюється в іншому місці, Provider отримує новий об’єкт state, і оскільки посилання відрізняється, ThemeLabel також перерисовується, хоча theme не змінився та компонент ніколи не торкається cart. У контексті немає вбудованого способу сказати «прокиньте мене лише тоді, коли зміниться це поле»; кожен споживач прокидається при кожній зміні значення.
Магазин з попереднього розділу вже уникає цієї проблеми. Функція useTheme() читає лише один фрагмент через метод getSnapshot, і компонент перерисовується лише тоді, коли значення цього фрагмента змінюється. Використання Context як проміжного рішення дозволяє заощадити місце під інсталяцію, але призводить до більшої кількості перерисовувань. Це можна пом’якшити, розділивши стан на кілька вузьких контекстів, але тоді ви самостійно будуєте те, що магазин на основі селекторів надає безкоштовно.
Де бібліотеки стану справді знаходять своє місце
Жоден із цих факторів не робить Redux, Zustand чи MobX марними. Чотири наведені аргументи виявилися хибними; бібліотеки — ні. Проблему передачі даних між компонентами вирішує Context. Зміну стану — useState. Автоматичні оновлення в непов’язаних компонентах — useSyncExternalStore, який постачається разом із React. А Context, який вважався компромісом, насправді є дорожчим за невеликий сховище для спільних, часто змінюваних значень.
Справжня потреба у бібліотеці виникає тоді, коли спільний стан має багато незалежних авторів змін, а не переважно читачів, і коли кількість компонентів, які його використовують, перевищує те, що можуть підтримувати в єдиному стані кілька ручно створених модульних сховищ. Саме при таких масштабах координація оновлень, використання middleware, інструментів розробки та прогнозоване відстеження змін робить зрілу бібліотеку корисною, і вона заслуговує на окремий огляд.
Основні висновки
- Коли єдиною проблемою є передача значень через шари, які їх не використовують, звертайтеся до Context, а не до магазину даних.
useState— це правильний інструмент для зміни стану, який належить одному компоненту.- Для стану, який спільний компонентам без спільного батьківського елемента, часто достатньо невеликого магазину даних, створеного за допомогою
useSyncExternalStore. - Один загальний Context переробляє всі компоненти-споживачі при кожній зміні; для часто змінюваних даних краще використовувати вузькі контексти або магазин даних на основі селекторів.
- Використовуйте бібліотеку для керування станом, коли у вас багато незалежних авторів даних та все більше читачів, а не через проблему передачі значень через багато рівнів.