React 18 додав useSyncExternalStore — хук, який дозволяє компонентам зчитувати дані з будь-якого джерела поза деревом React (браузерні API, WebSockets або сторонні сховища) без візуальних артефактів, які супроводжували старий підхід із дублюванням цих даних через useState та useEffect. Якщо ви коли-небудь бачили мерехтіння інтерфейсу через те, що одна його частина показувала старий розмір вікна, тоді як інша вже відображала новий, цей хук — саме те рішення, на яке ви чекали.

Чому зовнішньому стану потрібен новий хук

Компоненти React завжди могли підписуватися на об'єкти, що існують поза їхнім власним доменом: об'єкт window, сховище Redux або WebSocket-з'єднання. Традиційний підхід полягав у створенні підписки всередині useEffect, передачі вхідних значень у локальний стан через setState та запуску рендерингу React. Це добре працює, коли React рендерить синхронно, але React 18 представив конкурентний рендеринг (concurrent rendering), за якого компонент може рендеритися кілька разів, перш ніж браузер фактично оновить зображення на екрані. У такому режимі патерн «дзеркалювання у локальний стан» може призвести до tearing (розриву стану) — ситуації, коли різні частини інтерфейсу зчитують різні знімки (snapshots) одного й того самого зовнішнього значення протягом одного циклу рендерингу.

useSyncExternalStore був створений саме для запобігання tearing. Він запитує у зовнішнього джерела snapshot (поточне значення) та функцію subscribe (спосіб отримання сповіщень про зміни). React викликає subscribe, коли компонент монтується, і автоматично відписується під час демонтажу. Щоразу, коли snapshot, повернутий getSnapshot, змінюється, React планує рендеринг, який зчитує той самий snapshot для всього дерева компонентів, гарантуючи цілісність даних.

Дві необхідні функції

Функція Що вона робить
subscribe Приймає callback, який має викликатися щоразу, коли зовнішнє значення змінюється. Вона повертає функцію unsubscribe, яку React викличе під час очищення (cleanup).
getSnapshot Повертає поточне значення із зовнішнього джерела. Вона має бути referentially stable (стабільною за посиланням) — повертати той самий об'єкт функції при кожному рендерингу, щоб React міг коректно порівнювати snapshot-и.

Мінімальна реалізація для перевірки статусу online у браузері виглядає так:

function useOnlineStatus() {
  return useSyncExternalStore(
    (onChange) => {
      window.addEventListener('online', onChange);
      window.addEventListener('offline', onChange);
      return () => {
        window.removeEventListener('online', onChange);
        window.removeEventListener('offline', onChange);
      };
    },
    () => navigator.onLine
  );
}

Коли будь-який компонент викликає useOnlineStatus(), React перерендерить його лише тоді, коли navigator.onLine фактично зміниться, і кожен рендеринг бачитиме одне й те саме значення.

Правила, щоб хук не «зламався»

  1. Повертайте мінімально необхідний обсяг даних.
    Якщо зовнішнє сховище містить великий об'єкт, але компоненту потрібне лише одне поле, повертайте тільки це поле. Повернення більших об'єктів спричиняє зайві рендеринги.

  2. Мемоїзуйте getSnapshot.
    Якщо ви створюєте функцію заново під час кожного рендерингу, React сприйматиме її як нове джерело, що може призвести до нескінченного циклу. Огорніть її в useCallback або визначте поза межами компонента.

  3. Уникайте повернення нових об'єктів при кожному виклику.
    Повернення нового об'єкта (наприклад, { count: store.getCount() }) створює нове посилання при кожному рендерингу, через що React вважатиме, що snapshot змінився, і це запустить нескінченний цикл рендерингу. Використовуйте примітиви, рядки, числа або мемоїзовані об'єкти.

  4. Не використовуйте його для внутрішнього стану компонента.
    useState залишається правильним інструментом для даних, які існують тільки всередині компонента. useSyncExternalStore створює додаткове навантаження, яке не потрібне для локального стану.

Коли варто його використовувати

  • Browser APIs — розмір вікна, медіазапити, navigator.onLine, статус батареї.
  • Framework-agnostic stores — Zustand, Redux, MobX або будь-яке кастомне сховище, що надає API для підписки.
  • Mutable sources, що оновлюються поза React — повідомлення WebSocket, події змін IndexedDB, сповіщення Service Worker.

Якщо ваше джерело даних уже знаходиться всередині React (наприклад, стан батьківського компонента), використовуйте useState або context.

Що каже спільнота

Перші користувачі зазначають, що useSyncExternalStore усуває мерехтіння, яке спостерігалося під час зміни розміру вікон у режимі конкурентного рендерингу. Деякі бібліотеки вже перейшли на цей API для своїх внутрішніх хуків, що обіцяє більш передбачувану поведінку в різних версіях React. Ціною цього є дещо вище когнітивне навантаження: розробники мають стежити за стабільністю обох функцій і уникати повернення нових об'єктів при кожному виклику.

Що далі

Майбутні релізи React можуть посилити контракт навколо useSyncExternalStore, можливо, додавши вбудовані допоміжні функції для поширених браузерних API. Стежте за офіційним блогом React, щоб не пропустити оголошення застарілими старих патернів, що імітували useEffect. Тим часом хук є стабільним і є частиною публічного API, тому ви можете безпечно рефакторити існуючий код для роботи із зовнішнім станом.

Швидкий тест-драйв

Живе демо, що демонструє синхронізацію розміру вікна, виявлення статусу online/offline та простий Zustand store, доступне за адресою https://usesyncexternalstore.vercel.app/. Вихідний код із коментарями можна знайти за посиланням https://github.com/dev48v/usesyncexternalstore.

Головний висновок: useSyncExternalStore надає React надійний місток до будь-яких змінних даних, що знаходяться поза циклом рендерингу, запобігаючи розриву цілісності (tearing) та зайвим рендерам. Використовуйте його для зовнішніх джерел, тримайте snapshot мінімальним і стабільним, і дозвольте React взяти на себе складну роботу із забезпечення узгодженості.