Skip to main content

useTransition та useDeferredValue в React

useTransition позначає оновлення стану як низькопріоритетну роботу, яку React може перервати; useDeferredValue відкладає (defer) перерендер значення до завершення термінових оновлень.

Теорія

TL;DR

  • Аналогія: useTransition - це як сказати React "фільтруй список у фоні, поки я друкую" - поле введення реагує миттєво, результати підтягуються потім.
  • useTransition огортає сеттер стану і повертає [isPending, startTransition]; useDeferredValue огортає значення і повертає його відкладену копію.
  • Контролюєш сеттер стану: useTransition. Отримуєш значення як проп: useDeferredValue.
  • Потрібен індикатор завантаження: тільки useTransition має isPending. У useDeferredValue цього немає.
  • Обидва хуки потребують React 18 з конкурентним рендерингом (concurrent rendering).

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

tsx
import { useState, useTransition } from 'react'; function Search() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { setQuery(e.target.value); // Термінове: фіксується до наступного кадру startTransition(() => { setResults(heavySearch(e.target.value)); // Низький пріоритет: поступається введенню }); }; return ( <div> <input value={query} onChange={handleChange} /> {isPending && <p>Шукаємо...</p>} <ul>{results.map(r => <li key={r}>{r}</li>)}</ul> </div> ); }

Якщо друкувати швидко, поле введення оновлюється на кожен символ. isPending буде true поки фоновий фільтр працює. Результати з'являються після паузи.

Головна різниця

useTransition потребує доступу до сеттера стану. Ти викликаєш startTransition(() => setState(...)) і отримуєш isPending у відповідь. useDeferredValue потребує лише саме значення, тому він підходить для дочірніх компонентів, які отримують проп від батьківського і не контролюють його сеттер. Індикатора очікування немає, але застарілість (staleness) можна виявити, порівнявши оригінал з відкладеною копією: query !== deferredQuery.

Коли що використовувати

  • Пошукове поле з важкою фільтрацією на клієнті: useTransition (огортає setResults).
  • Дочірній компонент рендерить великий список з пропу: useDeferredValue (відкладає перерендер списку).
  • Перемикання вкладок з важким рендером: useTransition (нетермінова зміна вкладки + isPending для блокування кнопок).
  • Поле введення має реагувати миттєво, а відображення результатів може відставати: useDeferredValue.
  • Потрібен спінер: useTransition. Спінер не потрібен: обидва варіанти підходять, але useDeferredValue простіший.

Таблиця порівняння

useTransitionuseDeferredValue
Що відкладаєОновлення стануЗначення
Де застосовуватиКонтролюєш сеттерМаєш лише значення
Повертає[isPending, startTransition]Відкладену копію значення
Індикатор очікуванняВбудований isPendingПорівняти оригінал з відкладеним вручну
Типове місцеОбробник подіїТіло компонента або дочірній компонент

Як React планує це внутрішньо

React 18 призначає кожному оновленню "смугу" (lane) всередині файберного (fiber) reconciler. Термінові оновлення (події введення) отримують SyncLane або DefaultLane. Все, що огорнуто в startTransition, отримує TransitionLane з нижчим пріоритетом. Під час циклу рендеру планувальник запускає shouldYield() після кожної одиниці роботи. Якщо смуга з вищим пріоритетом має незавершену роботу, низькопріоритетний рендер зупиняється і відновлюється пізніше.

useDeferredValue використовує ту саму систему смуг без торкання сеттера. React рендерить компонент двічі: з поточним значенням (терміновий шлях) і з відкладеним (низькопріоритетний шлях). Відкладений рендер може бути перерваний у будь-який момент. Це власний файберний планувальник React, а не requestIdleCallback браузера.

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

1. Використовувати isPending замість перевірки порожнього стану

tsx
// Неправильно: крутиться навіть коли results справді порожній {results.length === 0 && <Spinner />} // Правильно: крутиться тільки під час активного переходу {isPending && <Spinner />}

2. Огортати сеттер поля введення в startTransition

Найчастіша помилка, яку я бачу на code review: сеттер введення потрапляє всередину переходу.

tsx
// Неправильно: поле введення гальмує, втрачається весь сенс startTransition(() => { setQuery(e.target.value); }); // Правильно: введення окремо, важка робота всередині setQuery(e.target.value); startTransition(() => setResults(heavySearch(e.target.value)));

3. Вважати, що useDeferredValue скасовує важкі обчислення

tsx
// Хибне припущення const deferred = useDeferredValue(expensiveCompute(query)); // expensiveCompute все одно виконується при кожному рендері

Відкладання змінює лише момент фіксації рендеру. expensiveCompute(query) запускається в будь-якому разі. Огортай важкий виклик у useMemo прив'язаний до відкладеного значення, або використовуй abort controller для асинхронної роботи.

4. Вкладати виклики startTransition

tsx
// Неправильно startTransition(() => { startTransition(() => setDeepState(val)); // Не підвищує пріоритет });

Використовуй один зовнішній startTransition. Вкладення не підвищує пріоритет і може призвести до непередбачуваних станів очікування.

5. Ігнорувати подвійний виклик у StrictMode

У режимі розробки з StrictMode, React 18 двічі викликає сеттери стану всередині переходів. isPending може мигати. Це нормальна поведінка в dev-режимі, у production вона відсутня.

Де використовується в реальних проектах

  • React DevTools: useTransition для перемикання фільтрів у дереві компонентів.
  • TanStack Query v5: огортає оновлення списку після мутацій у переходи.
  • Next.js App Router: useDeferredValue відкладає результати пошуку в динамічних сегментах маршрутів.
  • Vercel Commerce: дебаунсинг (debouncing) пошуку через відкладений запит до великого каталогу продуктів.

Питання на співбесіді

Q: У чому різниця між useTransition і standalone startTransition з react?
A: Standalone startTransition робить те саме, але нічого не повертає. Використовуй його у допоміжних функціях або обробниках подій поза компонентами, де isPending не потрібен.

Q: Як useDeferredValue взаємодіє з Suspense?
A: Якщо відкладений рендер звертається до призупиненого (suspended) ресурсу, React показує застарілий UI замість fallback Suspense, поки відкладений рендер не завершиться. Це запобігає спалаху до спінера на кожен символ введення.

Q: Чому isPending може довго залишатись true?
A: Якщо новий перехід стартує до завершення попереднього, прапор залишається true. Необроблена помилка всередині transition callback теж блокує чергу.

Q: Які пріоритети смуг використовує планувальник React?
A: Термінове введення: SyncLane або DefaultLane. Робота startTransition: TransitionLane. Фонові завдання: IdleLane. Reconciler зупиняється щоразу, коли shouldYield() повертає true.

Q: Як реалізувати витіснення (preemption) переходів у власному планувальнику?
A: Призначай низькопріоритетну смугу файберу переходу в scheduleUpdateOnFiber. У циклі роботи перевіряй shouldYield() після кожного файбера. Якщо вища смуга має роботу, виходь з циклу і став нове завдання для неї. Незавершена низькопріоритетна робота лишається в дереві і відновлюється після завершення пріоритетного проходу. React не викидає часткову роботу, а призупиняє її.

Приклади

Базовий: фільтрація великого списку з useTransition

tsx
import { useState, useTransition } from 'react'; const items = Array.from({ length: 10000 }, (_, i) => `Продукт ${i + 1}`); function ProductList() { const [query, setQuery] = useState(''); const [results, setResults] = useState(items); const [isPending, startTransition] = useTransition(); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); // Фіксується до запуску фільтра startTransition(() => { setResults( items.filter(item => item.toLowerCase().includes(value.toLowerCase())) ); }); }; return ( <div> <input value={query} onChange={handleChange} placeholder="Пошук..." /> {isPending && <p>Фільтруємо...</p>} <ul> {results.map(item => <li key={item}>{item}</li>)} </ul> </div> ); }

Поле введення фіксується до запуску фільтра. При 10к елементах фільтрація займає час; isPending показує повідомлення поки вона працює. Жодного debounce не потрібно.

Середній: useDeferredValue в дочірньому компоненті

tsx
import { useState, useDeferredValue, useMemo } from 'react'; interface Product { id: number; name: string; } function ProductResults({ query, products }: { query: string; products: Product[] }) { const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; // true поки відкладений відстає const filtered = useMemo( () => products.filter(p => p.name.toLowerCase().includes(deferredQuery.toLowerCase()) ), [products, deferredQuery] // Перераховується лише при зміні deferredQuery ); return ( <ul style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 0.15s' }}> {filtered.map(p => <li key={p.id}>{p.name}</li>)} </ul> ); } function App({ products }: { products: Product[] }) { const [query, setQuery] = useState(''); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <ProductResults query={query} products={products} /> </div> ); }

ProductResults не має доступу до сеттера, тому useTransition тут не варіант. useDeferredValue відкладає фільтр до фіксації введення. Зниження прозорості сигналізує, що свіжіший результат вже готується.

Просунутий: обидва хуки в одному компоненті

tsx
import { useState, useTransition, useDeferredValue, useMemo } from 'react'; type Tab = 'active' | 'archived'; interface Item { id: number; name: string; status: Tab; } function AdminDashboard({ items }: { items: Item[] }) { const [query, setQuery] = useState(''); const [tab, setTab] = useState<Tab>('active'); const [isPending, startTransition] = useTransition(); const deferredQuery = useDeferredValue(query); const handleTabChange = (next: Tab) => { startTransition(() => setTab(next)); // Нетермінове, але сеттер твій }; const visible = useMemo( () => items .filter(item => item.status === tab) .filter(item => item.name.toLowerCase().includes(deferredQuery.toLowerCase()) ), [items, tab, deferredQuery] ); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Фільтр..." /> <button onClick={() => handleTabChange('active')} disabled={isPending}> Активні {isPending && '...'} </button> <button onClick={() => handleTabChange('archived')} disabled={isPending}> Архів {isPending && '...'} </button> <ul style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}> {visible.map(item => <li key={item.id}>{item.name}</li>)} </ul> </div> ); }

Кнопки вкладок використовують useTransition, бо ти контролюєш setTab. Список використовує useDeferredValue для запиту, бо фільтру потрібне лише значення. Обидва хуки в одному компоненті, кожен на своєму місці.

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

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

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

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