Що таке батчинг у React?
Батчинг (batching) у React — це об'єднання кількох оновлень стану в один рендер, щоб уникнути зайвих проміжних перемальовувань.
Теорія
TL;DR
- Батчинг схожий на шеф-кухаря, який нарізає всі інгредієнти до того, як вмикає плиту: виклики
setStateвідбуваються швидко, але рендер запускається один раз. - Без батчингу 3 виклики
setState= 3 рендери. З батчингом = 1. - React 17 батчив тільки всередині React-подій. React 18 батчить скрізь, включно з промісами і таймаутами.
flushSync— це не інструмент для батчингу. Він навпаки зламує батчинг і форсує синхронний рендер одразу.
Швидкий приклад
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
setCount(c => c + 1); // поставлено в чергу
setFlag(f => !f); // поставлено в чергу
// React батчить обидва → один рендер
};
return <button onClick={handleClick}>Count: {count}, Flag: {flag}</button>;
}Обидва оновлення збираються разом і React записує їх за один прохід. Компонент рендериться один раз, не двічі.
React 17 проти React 18
У React 17 батчинг працював тільки всередині React-подій: onClick, onChange і подібні. Якщо ті ж самі виклики setState перемістити в setTimeout або Promise.then — отримуєш два окремих рендери. Це було поширеною причиною непомітних проблем з продуктивністю в асинхронному коді.
React 18 змінив це новою моделлю планувальника. Тепер батчинг працює скрізь: обробники подій, асинхронні колбеки, таймаути, інтервали. Поведінка однакова незалежно від того, де відбувається оновлення.
// React 17: 2 рендери. React 18: 1 рендер.
function PromiseUpdater() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const trigger = () => {
Promise.resolve().then(() => {
setA(1); // React 17: рендер тут
setB(2); // React 17: ще один рендер; React 18: батчинг
});
};
return <button onClick={trigger}>A: {a}, B: {b}</button>;
}Коли що використовувати
- Кілька
setStateв одному обробнику події: нічого не робиш, автоматично і в React 17, і в 18. - Оновлення стану в
Promise.thenабоsetTimeoutна React 18+: теж автоматично. - Оновлення в
Promise.thenна React 17: оберни вunstable_batchedUpdatesзreact-dom. - Потрібно синхронно прочитати DOM одразу після зміни стану: використовуй
flushSync, але тільки для цього.
Як батчинг працює всередині
React збирає оновлення стану в чергу (частина react-reconciler) в межах одного update lane. Замість того щоб викликати commitRoot після кожного setState, він чекає поки поточний scope завершиться, потім скидає всю чергу одним проходом і рендерить раз. У React 18 планувальник використовує пріоритетні lanes: термінові оновлення (взаємодія користувача) відокремлені від відкладених (переходи). Саме тому useTransition може відкладати низькопріоритетну роботу, не торкаючись батчингу термінового шляху.
Типові помилки
Помилка 1: Вважати що батчинг в React 17 працює скрізь.
// React 17: 2 рендери
setTimeout(() => {
setA(1); // рендер
setB(2); // ще рендер
}, 0);
// Фікс для React 17
import { unstable_batchedUpdates } from 'react-dom';
setTimeout(() => {
unstable_batchedUpdates(() => {
setA(1);
setB(2);
});
}, 0);Фікс працює. Але unstable_batchedUpdates має цей префікс не просто так. Оновлення до React 18 усуває проблему повністю.
Помилка 2: Використовувати flushSync щоб "увімкнути" батчинг.
Він робить протилежне. flushSync зламує батчинг і форсує синхронний рендер на місці. Використовуй тільки коли потрібно виміряти DOM до наступного кадру.
flushSync(() => setCount(1)); // синхронний рендер тут
const rect = ref.current.getBoundingClientRect(); // тепер безпечно вимірюватиПомилка 3: Очікувати що startTransition-оновлення батчаться разом з терміновими.
useTransition позначає оновлення як низькопріоритетні. React може виконати їх окремим проходом від термінових. Термінове і перехідне оновлення можуть потрапити в різні рендери. Це поведінка за задумом.
const [isPending, startTransition] = useTransition();
startTransition(() => {
setFilter('done'); // низький пріоритет, може рендеритись окремо
});Помилка 4: Тестувати батчинг через StrictMode.
StrictMode монтує компоненти двічі тільки в режимі розробки. Він не симулює поведінку батчингу у продакшені. Для підрахунку реальних render-комітів використовуй React DevTools Profiler.
Де зустрічається в реальних проектах
- Redux Toolkit: утиліта
batchзreact-reduxогортає кілька dispatches щоб тригернути один рендер стору. - React Query:
useMutationбатчить оптимістичні оновлення, тому форми не мигають при сабміті. - Next.js: мутації
useSWRавтоматично батчаться під час SSR-гідратації в React 18. - Zustand:
batch(fn)для одночасного оновлення кількох сторів.
Додаткові питання
Q: Як батчинг змінився між React 17 і React 18?
A: React 17 батчив тільки всередині React-подій. React 18 автоматично батчить у всіх контекстах (проміси, таймаути, інтервали) через новий планувальник з lanes-моделлю.
Q: Коли батчинг НЕ відбувається?
A: У React 17 поза React-подіями. У будь-якій версії при виклику flushSync — він форсує синхронний рендер і одразу виходить з черги батчингу.
Q: Що таке unstable_batchedUpdates і чи варто його використовувати?
A: Це legacy API для форсування батчингу поза React-подіями в React 17. Використовуй тільки якщо оновлення до React 18 неможливе. Префікс "unstable" навмисний.
Q: Як батчинг взаємодіє з useTransition?
A: Переходи (transitions) є низькопріоритетними оновленнями. React може виконати їх окремим проходом від термінових, тому вони не завжди батчаться разом із синхронними змінами стану. Це очікувана поведінка.
Q: Як би ти дебажив компонент, який рендериться забагато разів попри батчинг?
A: Відкриваю React DevTools Profiler і записую взаємодію. Шукаю компоненти з кількома комітами на одну дію користувача. Далі перевіряю наявність flushSync, сторонні бібліотеки що викликають setState поза React-подіями, або елементи списків без key що змушують React перебудовувати все піддерево. Бібліотека why-did-you-render додає логи по кожному компоненту і показує точно яка пропс або стейт-змінна змінилась між рендерами.
Приклади
Базовий: два оновлення, один рендер
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const reset = () => {
setName(''); // в чергу
setEmail(''); // в чергу
// обидва очищуються за один рендер
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<button onClick={reset}>Reset</button>
</div>
);
}Один клік, два setState, один рендер. Без батчингу форма мигнула б через проміжний стан де name вже порожній, а email ще ні.
Середній: фільтрація зі збатченим станом (патерн TodoMVC)
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
const [visibleTodos, setVisibleTodos] = useState(todos);
const changeFilter = (newFilter) => {
setFilter(newFilter); // в чергу
setVisibleTodos( // в чергу
todos.filter(t =>
newFilter === 'all' || t.status === newFilter
)
);
// один рендер: мітка фільтра і список оновлюються разом
};
return (
<div>
<button onClick={() => changeFilter('done')}>Показати виконані</button>
<ul>{visibleTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
</div>
);
}Якби ці два оновлення рендерились окремо, користувач побачив би кадр де мітка фільтра вже каже «виконані», але список досі показує всі задачі. Батчинг не дає такому розірваному стану потрапити на екран.
Просунутий: асинхронна пастка (React 17 проти 18)
Якось я дебажив дашборд де кожний запит даних давав два помітних моргання інтерфейсу. Компонент оновлював стан всередині Promise.then на React 17. Ось спрощений варіант:
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true); // React 17: рендер #1
const result = await fetch('/api/stats').then(r => r.json());
// Всередині Promise.then — React 17 НЕ батчить ці виклики
setData(result); // React 17: рендер #2
setLoading(false); // React 17: рендер #3
// React 18: один рендер після await
};
return (
<div>
{loading && <p>Завантаження...</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
<button onClick={fetchData}>Завантажити</button>
</div>
);
}У React 17 це три окремих рендери, включно з кадром де data вже є, але loading ще true. Саме це і давало моргання. У React 18 два пост-await оновлення автоматично батчаться. Якщо ти на React 17 і не можеш оновитись, оберни пост-await оновлення в unstable_batchedUpdates.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.