Робота зі станом у React-додатку з потоковою передачею даних: Context, Redux Toolkit та RTK Query
Пройдіть шлях через фронтенд відеострімінгу — від пропсів до вкладених провайдерів, через Redux Toolkit slices та RTK Query — і дізнайтеся, який тип стану належить до кожного інструменту.
Майже кожен розвиваючийся React-додаток доходить до моменту, коли кореневий компонент опиняється під купою провайдерів, а таймер відтворення якимось чином знову відрендеровує індикатор сповіщень. Рішенням рідко є одна-єдина бібліотека; це усвідомлення того, що різні типи стану потребують різних місць зберігання. У цьому посібнику як приклад використовується фронтенд для стрімінгу відео, де розглядається процес передачі даних через prop drilling, Context API, Redux Toolkit та RTK Query, а на завершення наводиться практичне правило для вибору між ними.
Приклад: фронтенд для стрімінгу
- входження та рівні підписки — безкоштовний чи преміум
- список перегляду та рядок „Продовжити перегляд“
- стан плеєра: поточний епізод, прогрес відтворення та налаштування якості
- навігація по каталозу та пошук з фільтрами за жанром
Кожен з цих елементів має знаходитися в якомусь місці дерева компонентів, і кілька компонентів, розташованих далеко один від одного, потребують їх отримання. Саме в такій комбінації ранні рішення щодо стану або приносять користь, або через кілька місяців призводять до повільних та дорогих процедур рефакторингу.
Етап перший: проколювання передачі даних
Першим інстинктом є переміщення стану до найближчого спільного предка та його передача далі. Для невеликих додатків це правильне рішення. Однак у інтерфейсі стрімінгу шлях від кореня до кнопки може бути довгим:
App → MainLayout → ContentSection → AnimeGrid → AnimeCard → PlayButton
Припустимо, що PlayButton має знати рівень підписки, щоб вирішити, чи відображати іконку блокування преміум-функцій. Цей рівень має передаватися через MainLayout, ContentSection та AnimeGrid, проте жоден з них його не використовує. Вони існують у цьому ланцюжку лише як посередники.
Одна проп, яка проходить через дрилінг, ще терпима. Проблеми починаються тоді, коли ще одна, не пов’язана з нею величина, наприклад список для стеження, мусить проходити тим самим шляхом. Кожен проміжний компонент тепер містить пропи, яких він не розуміє, що ускладнює їх повторне використання в інших місцях, тестування окремо та розуміння їх функцій. Перш ніж звертатися до бібліотеки, варто прочитати чому сам дрилінг пропів не є підставою для встановлення Redux чи Zustand; композиція часто скорочує ці ланцюги. Однак у цьому додатку дані справді є глобальними.
Другий етап: контекст та піраміда провайдерів
API Context у React — це природний наступний крок. Ви створюєте AuthContext, обгортаєте дерево в AuthProvider, і PlayButton отримує дані безпосередньо за допомогою useContext(AuthContext). Таким чином зникає необхідність глибокого проникнення в структуру.
Потім з’являються інші глобальні проблеми, кожна з яких має власного провайдера, і коренева структура починає виглядати ось так:
<AuthProvider>
<SubscriptionProvider>
<WatchlistProvider>
<PlayerProvider>
<NotificationProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</NotificationProvider>
</PlayerProvider>
</WatchlistProvider>
</SubscriptionProvider>
</AuthProvider>
Це те, що розробники називають «пеклом провайдерів»: корень додатку перетворюється на набір вкладених обгорток, кожна з яких додає ще один рівень непрямості. Візуальний шум — це ще найменша з проблем.
Для відлову помилок потрібна карта
Коли відтворення працює некоректно, спочатку потрібно з’ясувати, який провайдер керує цим станом, а потім простежити структуру вкладень, щоб знайти місце змін. Сама деревоподібна структура цього не показує.
Кожне оновлення доходить до кожного споживача
Зміна значення контексту змушує переробити всі компоненти, які використовують цей контекст, навіть ті, що використовують лише його частину. Для продовження відстеження необхідно зберігати значення playbackProgress кожні кілька секунд. Якщо це значення знаходиться в PlayerContext разом із налаштуванням якості, компонент, який лише відображає індикатор якості, все одно переробляється при кожному оновленні.
Порядок постачальників стає неявним контрактом
WatchlistProvider потребує ID увійшового користувача від AuthProvider, тому його необхідно розмістити всередині останнього. У JSX немає жодних позначок, які б чітко вказували на цю залежність, а зміна порядку постачальників під час рефакторингу може пошкодити додаток у спосіб, який важко відстежити.
Реалістичний симптом: команда поєднує контекст гравця та контекст сповіщення, і аналіз показує, що оновлення прогресу спричиняють повторне відображення сповіщень. Нічого не виглядає пошкодженим, але React Profiler відображає значно більше операцій відображення, ніж потребує інтерфейс.
Контекст можна налаштувати, наприклад, шляхом розділення швидко змінюваних значень у власний контекст або зберігання значень провайдерів у мемо, але кожне рішення додає більше провайдерів та складнощів. У такому випадку спеціалізований стор часто є простішим варіантом.
Третій етап: Redux Toolkit slices
Багато команд на цьому етапі вдаються до легших сторів, таких як Zustand. Redux Toolkit залишається хорошим вибором, коли у вас є кілька пов’язаних фрагментів стану, потрібна можливість налагодження з використанням механізму «подорож у часі», і ви цінуєте єдине передбачуване джерело істини; крім того, він усуває більшу частину зайвого коду, який ускладнював роботу з класичним Redux.
Кожна проблема стає окремою частиною. Частина гравця нижче містить поточну серію, прогрес та якість, а також визначає функції-редюсери для зміни серії та оновлення прогресу. Зверніть увагу, що ці функції-редюсери, здається, безпосередньо мутують state; проте Redux Toolkit використовує Immer під капотом, тому такі присвоєння безпечно створюють новий незмінний стан:
// playerSlice.js
const playerSlice = createSlice({
name: 'player',
initialState: {
currentEpisode: null,
playbackProgress: 0,
quality: '1080p',
},
reducers: {
setEpisode: (state, action) => {
state.currentEpisode = action.payload;
},
updateProgress: (state, action) => {
state.playbackProgress = action.payload;
},
},
});
Вбудовувані структури зникли. Одна лише команда <Provider store={store}> оточує додаток, а компоненти читають лише те, що їм потрібно, за допомогою useSelector. Оскільки useSelector порівнює обраний значення між рендерингами, PlayButton, який вибирає рівень підписки, перерендерується лише тоді, коли рівень змінюється, а не щоразу, коли оновлюється прогрес. Ця селективна модель підписки усуває більшість зайвих рендерингів, які виникали у версії з Context.
Ще однією важливою перевагою є Redux DevTools. Перегляд кожної відправленої дії – наприклад, натискання кнопки «програти», оновлення прогресу чи зміна епізоду – та точне спостереження за тим, як змінюється стан, значно полегшує діагностику проблем під час відтворення порівняно з відстеженням значень через ієрархію постачальників даних.
Четвертий етап: RTK Query для стану сервера
Більша частина складності додатку зовсім не пов’язана із станом користувацького інтерфейсу. Йдеться про стан сервера: каталог, результати пошуку, деталі епізодів та список для перегляду, які зберігаються на серверній частині. Традиційний підхід поєднує useEffect із кількома викликами useState за кожним запитом для ручного відстеження даних, статусу завантаження та помилок, що часто призводить до конфліктів та дублювання запитів.
RTK Query замінює це шматком API. Наведене нижче визначення встановлює базову URL та оголошує два кінцеві точки запиту: одну для аніме, відфільтрованого за жанром, та одну для деталей епізоду:
export const catalogApi = createApi({
reducerPath: 'catalogApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getAnimeList: builder.query({
query: (genre) => `/anime?genre=${genre}`,
}),
getEpisodeDetails: builder.query({
query: (episodeId) => `/episodes/${episodeId}`,
}),
}),
});
RTK Query генерує хук React для кожної кінцевої точки, який називається так само, як і вона, і його експортують з цього шматка:
export const { useGetAnimeListQuery, useGetEpisodeDetailsQuery } = catalogApi;
Потім компонент отримує дані, статус завантаження та помилки в одному рядку:
const { data: animeList, isLoading, error } = useGetAnimeListQuery('action');
Немає вручну написаного ефекту та жодного ручного флага завантаження. Основною перевагою є кешування. Якщо користувач відкриває розділ Action, виходить та повертається, кешований список з’являється миттєво, а RTK Query у фоновому режимі оновлює дані, якщо вони застаріли. Ідентичні запити з кількох компонентів використовують один мережевий виклик.
У режимі безперервного перегляду використання тегів для скасування даних забезпечує точність інтерфейсу: запити вказують, які дані вони надають, за допомогою providesTags, а мутації — що саме вони змінюють, за допомогою invalidatesTags. Коли виконується мутація для оновлення статусу, RTK Query автоматично перезавантажує відповідні запити, тож жодному компоненту не потрібно вручну ініціювати перезавантаження. Для тегів потрібна запис tagTypes у API slice та кінцева точка для мутацій, причому жоден з цих елементів не вказаний у наведеному вище фрагменті; наш посібник щодо надсилання даних за допомогою мутацій RTK Query розглядає цю тему. Також пам’ятайте, що редьюсер та мідлвейр API slice мають бути додані до store, щоб кешування функціонувало.
Призначення кожного типу стану для певного інструменту
Для подібного додатку доцільний розподіл виглядає так:
- Локальний стан за допомогою
useStateдля всього, що ніколи не залишає компонент: полі форм, перемикачі, стани наведення курсору та відкриття. - Context API для простих глобальних значень, які змінюються рідко, таких як тема чи мова. Він не ефективний, коли існує багато контекстів чи значень, які оновлюються часто.
- Redux Toolkit для складних, взаємопов’язаних клієнтських станів, таких як автентифікація, рівень підписки, стан плеєра та список для спостереження, які читаються та записуються багатьма незалежними компонентами.
- RTK Query для всього, що надходить з бекенду, що допомагає усунути цілу категорію проблем: застарілі дані, конкуренція запитів та зайві завантаження.
Поширеною помилкою є сприйняття цього як вибору «або все, або нічого» — все або в Context, або все в Redux. Ці інструменти вирішують різні проблеми, і зрілий додаток зазвичай поєднує їх.
Ключові висновки
- Prop drilling є сигналом до первинної реструктуризації; звертайтеся до глобального стану лише тоді, коли дані справді потрібні у віддалених частинах структури.
- Context переробляє кожен компонент-споживач при кожній зміні, що робить його поганим вибором для часто змінюваних значень, таких як прогрес відтворення.
- Приховані залежності між постачальниками даних є ризиком для підтримки, який зростає з кожним новим Context.
- Підписки на події на основі селекторів у Redux Toolkit та інструменти розробки роблять спільний клієнтський стан як швидшим у використанні, так і простішим для дебагування.