Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке useSyncExternalStore в React?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)`useSyncExternalStore` - це хук React 18 для підписки на зовнішні джерела даних (API браузера, сторонні сховища), який запобігає tearing під час конкурентного рендерингу. React захоплює один snapshot за рендер-пас і повторно використовує його для всього дерева компонентів. ```tsx const isOnline = useSyncExternalStore( (cb) => (window.addEventListener("online", cb), () => window.removeEventListener("online", cb)), () => navigator.onLine, () => true // значення для SSR ); ``` **Ключове:** використовуй для даних, які живуть поза системою стану React.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**`useSyncExternalStore`** - це хук React 18, який підписує компоненти на зовнішні джерела даних і гарантує однакове значення snapshot для всіх зчитувань в одному рендері, запобігаючи tearing (розриву) при конкурентному рендерингу. ## Теорія ### TL;DR - Аналогія: **спільна дошка на нараді** - всі учасники бачать однаковий контент в будь-який момент, ніхто не бачить суміш старих і нових даних - Проблема, яку вирішує: конкурентний React може призупинити рендер посередині, тому прямий `navigator.onLine` може повернути різні значення в різних місцях одного рендеру - Три аргументи: `subscribe` (підключити слухача), `getSnapshot` (прочитати поточне значення), `getServerSnapshot` (опціонально, для SSR) - Використовуй для API браузера, сторонніх сховищ і глобальних змінних поза React. Для `useState`/`useReducer` не потрібен - React і так керує ними коректно ### Швидкий приклад ```tsx 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`** ```tsx // Неправильно: новий об'єкт при кожному виклику = нескінченні ре-рендери const getSnapshot = () => ({ count: externalStore.count }); // Правильно: повертаємо примітив const getSnapshot = () => externalStore.count; ``` React викликає `getSnapshot` кілька разів і перевіряє чи змінився результат. Інший референс - ре-рендер. Завжди повертай примітиви або мемоізуй об'єктні snapshots. **Мутація стану всередині `getSnapshot`** ```tsx // Неправильно: побічний ефект у snapshot const snap = () => { externalStore.value++; return externalStore.value; }; // Правильно: тільки чисте зчитування const snap = () => externalStore.value; ``` `getSnapshot` має бути чистою функцією. Мутації тут спричиняють нескінченні цикли оновлень. **Відсутній cleanup в `subscribe`** ```tsx // Неправильно: слухач ніколи не видаляється (cb) => { window.addEventListener("resize", cb); } // Правильно: повертаємо функцію очищення (cb) => { window.addEventListener("resize", cb); return () => window.removeEventListener("resize", cb); } ``` Без cleanup кожен ре-рендер додає ще один дублікат слухача. Витік пам'яті, і колбек спрацьовує кілька разів на одну подію. **Пропущений `getServerSnapshot`** ```tsx // Неправильно: падає на сервері де 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-роботу. ## Приклади ### Відстеження статусу мережі ```tsx 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 ```tsx 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) ```tsx 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.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.