Що таке useSyncExternalStore в React?
useSyncExternalStore - це хук React 18, який підписує компоненти на зовнішні джерела даних і гарантує однакове значення snapshot для всіх зчитувань в одному рендері, запобігаючи tearing (розриву) при конкурентному рендерингу.
Теорія
TL;DR
- Аналогія: спільна дошка на нараді - всі учасники бачать однаковий контент в будь-який момент, ніхто не бачить суміш старих і нових даних
- Проблема, яку вирішує: конкурентний React може призупинити рендер посередині, тому прямий
navigator.onLineможе повернути різні значення в різних місцях одного рендеру - Три аргументи:
subscribe(підключити слухача),getSnapshot(прочитати поточне значення),getServerSnapshot(опціонально, для SSR) - Використовуй для API браузера, сторонніх сховищ і глобальних змінних поза React. Для
useState/useReducerне потрібен - React і так керує ними коректно
Швидкий приклад
function useOnlineStatus() {
return useSyncExternalStore(
// subscribe: підключаємо слухача, повертаємо cleanup
(cb) => {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
},
() => navigator.onLine, // getSnapshot: чисте зчитування
() => true // getServerSnapshot: значення для SSR
);
}React викликає getSnapshot один раз за рендер і повторно використовує це значення для всього дерева. Жодна мутація під час рендеру не може змінити те, що бачить компонент.
Що таке tearing і звідки він береться
Конкурентний React ділить роботу на частини різного пріоритету. Обробляючи велике дерево компонентів, планувальник може призупинитись, виконати більш пріоритетну задачу, а потім продовжити. Якщо компонент читає navigator.onLine напряму, одна частина дерева може отримати true (до зміни мережі), а інша - false (після зміни) в одному й тому ж коміті. UI показує обидва стани одночасно. Це і є tearing.
useSyncExternalStore вирішує це, фіксуючи один атомарний snapshot через getSnapshot для всього рендер-пасу.
Коли використовувати
- Зміни API браузера (online/offline, media queries,
window.innerWidth) - використовуйuseSyncExternalStore - Сторонні сховища без вбудованих React-прив'язок (внутрішність Zustand, Redux DevTools) - використовуй
useSyncExternalStore - Будь-яке мутабельне значення поза React, яке може змінитись без відома React - використовуй
useSyncExternalStore - Звичайний React-стан (
useState,useReducer) - хук не потрібен, React і так обробляє їх без tearing
Як це працює всередині
React викликає getSnapshot() один раз за рендер-пас і кешує результат для кожного компонента. Усі дочірні елементи, що читають з того ж джерела, отримують це кешоване значення. Коли subscribe спрацьовує (зовнішнє джерело змінилось), React ставить ре-рендер у чергу через свій планувальник. Референс колбека залишається стабільним між рендерами, тому підписки не накопичуються. На сервері замість getSnapshot виконується getServerSnapshot, бо там немає window.
З досвіду в продакшені: найчастіший баг - це повернення нового об'єкта або масиву всередині getSnapshot. React порівнює snapshots за референсом, тому { count: store.count } при кожному виклику викличе нескінченні ре-рендери. Повертай примітиви або стабільні референси.
Типові помилки
Повернення нового об'єкта в getSnapshot
// Неправильно: новий об'єкт при кожному виклику = нескінченні ре-рендери
const getSnapshot = () => ({ count: externalStore.count });
// Правильно: повертаємо примітив
const getSnapshot = () => externalStore.count;React викликає getSnapshot кілька разів і перевіряє чи змінився результат. Інший референс - ре-рендер. Завжди повертай примітиви або мемоізуй об'єктні snapshots.
Мутація стану всередині getSnapshot
// Неправильно: побічний ефект у snapshot
const snap = () => { externalStore.value++; return externalStore.value; };
// Правильно: тільки чисте зчитування
const snap = () => externalStore.value;getSnapshot має бути чистою функцією. Мутації тут спричиняють нескінченні цикли оновлень.
Відсутній cleanup в subscribe
// Неправильно: слухач ніколи не видаляється
(cb) => { window.addEventListener("resize", cb); }
// Правильно: повертаємо функцію очищення
(cb) => {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
}Без cleanup кожен ре-рендер додає ще один дублікат слухача. Витік пам'яті, і колбек спрацьовує кілька разів на одну подію.
Пропущений getServerSnapshot
// Неправильно: падає на сервері де window = undefined
useSyncExternalStore(subscribe, () => window.innerWidth);
// Правильно: передаємо серверне значення за замовчуванням
useSyncExternalStore(subscribe, () => window.innerWidth, () => 1024);Без третього аргументу виникають hydration mismatch помилки. Сервер рендерить без значення, клієнт гідрується з реальним - React попереджає або кидає помилку.
Використання для звичайного React-стану
Якщо дані живуть у useState або useReducer, цей хук нічого не додає. React і так керує ними без tearing. Він потрібен тільки для даних поза системою стану React.
Де зустрічається в реальних проектах
- Zustand (v4+): використовує
useSyncExternalStoreдля всіх підписок на сховище - Redux Toolkit: забезпечує snapshots для time-travel в DevTools
- TanStack Query: відстежує фокус і blur вікна для логіки фонового рефетчу
- Framer Motion: зчитує pointer і gesture API з браузера
- Будь-який продакшн-хук для адаптивної верстки (media queries, розміри viewport)
Питання на співбесіді
Q: Що таке tearing і чому це важливо тільки в конкурентному React?
A: Tearing - це коли дві частини одного рендеру читають зовнішнє значення в різні моменти і отримують різні результати, що дає суперечливий UI. У синхронному React рендери не перериваються, тому це неможливо. Конкурентний React призупиняє і відновлює роботу, і саме це відкриває можливість для tearing.
Q: Чим це відрізняється від useEffect + useState?
A: Підхід через useEffect дає затримку на один рендер: спочатку підписуємось в ефекті, оновлюємо стан, потім ре-рендер. З useSyncExternalStore зчитування синхронне і прив'язане до самого рендеру. Жодного застарілого рендеру, жодного проміжного мерехтіння.
Q: Що буде на SSR якщо пропустити getServerSnapshot?
A: React кине помилку в dev-режимі і видасть hydration mismatch у продакшені. Завжди передавай серверне значення за замовчуванням, яке не залежить від API браузера.
Q: Чому getSnapshot має повертати стабільне значення між викликами, якщо нічого не змінилось?
A: React викликає getSnapshot кілька разів під час конкурентної роботи, щоб перевірити наявність мутацій. Якщо функція повертає різні значення при повторних викликах без реальної зміни сховища - React покаже попередження і може форсувати синхронний ре-рендер.
Q (senior): Як useSyncExternalStore взаємодіє з startTransition?
A: startTransition позначає оновлення стану як низькопріоритетне, відкладаючи його. Але зовнішні сховища через useSyncExternalStore вважаються синхронними для планувальника React. Якщо оновлення сховища відбувається під час transition, React виконає його синхронно щоб уникнути tearing, навіть якщо це перерве поточну transition-роботу.
Приклади
Відстеження статусу мережі
function useOnlineStatus() {
return useSyncExternalStore(
(cb) => {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
},
() => navigator.onLine,
() => true // сервер: вважаємо що онлайн
);
}
function NetworkBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return <div className="banner">Відсутнє з'єднання з мережею</div>;
}getSnapshot - це чисте зчитування navigator.onLine. Одне і те ж значення повертається для всього рендер-пасу, незалежно від того, коли саме спрацював мережевий ивент.
Адаптивна верстка через media query
function useIsMobile() {
return useSyncExternalStore(
(cb) => {
const mql = window.matchMedia("(max-width: 768px)");
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
},
() => window.matchMedia("(max-width: 768px)").matches,
() => false // SSR: вважаємо десктоп
);
}
function Sidebar() {
const isMobile = useIsMobile();
return (
<nav className={isMobile ? "sidebar-mobile" : "sidebar-desktop"}>
{/* стабільний layout, без мерехтіння при зміні розміру */}
</nav>
);
}SSR-значення false запобігає hydration mismatch на десктоп-орієнтованих лейаутах. Без getServerSnapshot сервер і клієнт рендерять різні класи і React кидає попередження.
Інтеграція зовнішнього сховища (як це працює в Zustand)
const counterStore = {
count: 0,
listeners: new Set<() => void>(),
increment() {
this.count++;
this.listeners.forEach((l) => l());
},
subscribe(cb: () => void) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
},
getSnapshot() {
return this.count;
},
};
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe.bind(counterStore),
counterStore.getSnapshot.bind(counterStore),
() => 0
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => counterStore.increment()}>+1</button>
</div>
);
}Сховище мутує напряму поза React, але useSyncExternalStore підключає його до циклу рендерингу. Кожен виклик increment повідомляє всіх слухачів, React планує ре-рендер, і getSnapshot повертає нове значення. Приблизно так влаштовані React-прив'язки Zustand v4.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.