19. useSyncExternalStore
TypeScript: Синхронизация Внешних Источников с useSyncExternalStore
Заголовок раздела «TypeScript: Синхронизация Внешних Источников с useSyncExternalStore»Привет, кодер! Яша снова на связи. Сегодня мы погрузимся в одну из самых мощных, но часто недооцененных возможностей React для работы с внешними источниками данных – хук useSyncExternalStore. Если ты когда-то ломал голову, как правильно подружить глобальный стор, браузерные API или, скажем, WebSockets с состоянием React, избегая “разрывов” (tearing) и лишних ре-рендеров, то этот урок для тебя.
🤔 Что такое “Внешний Источник” и Зачем Его Синхронизировать?
Заголовок раздела «🤔 Что такое “Внешний Источник” и Зачем Его Синхронизировать?»Представь React-компонент как актера на сцене. У него есть свой реквизит (состояние), и он точно знает, когда и что менять. Но иногда актеру нужно взаимодействовать с тем, что происходит “за кулисами” – например, с глобальным освещением, звуковым пультом или даже с другими спектаклями, идущими в соседнем зале. Эти “закулисье” и есть внешние источники.
Внешний источник – это любой источник данных, который не управляется напрямую React. Это может быть:
- Глобальный стейт-менеджер (Redux, Zustand, Vuex-подобные хранилища).
- Браузерные API (
localStorage,sessionStorage,navigator.onLine,window.matchMedia,IntersectionObserver). - Сокеты (WebSockets, Server-Sent Events).
- Сторонние библиотеки с собственным управлением состоянием.
Проблема в том, что React рендерит твой UI в несколько этапов, и между этими этапами внешние данные могут измениться. Если ты просто читаешь их в useEffect или useState, есть риск, что разные части твоего дерева компонентов увидят разные версии внешнего состояния в рамках одного цикла рендера. Это называется “разрыв” (tearing) – когда UI отображает несогласованное состояние.
useSyncExternalStore создан для того, чтобы решить эту проблему элегантно и эффективно. Он гарантирует, что все компоненты, которые подписываются на внешний стор, всегда получают одну и ту же, актуальную версию состояния, даже при конкурентных рендерах React.
🛠️ Как Работает useSyncExternalStore?
Заголовок раздела «🛠️ Как Работает useSyncExternalStore?»Этот хук принимает три аргумента: subscribe, getSnapshot и getServerSnapshot.
function useSyncExternalStore<Snapshot>( subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => Snapshot, getServerSnapshot?: () => Snapshot): Snapshot;Давай разберем каждый из них:
-
subscribe:- Это функция, которая принимает колбэк
onStoreChange. - Этот колбэк должен быть вызван каждый раз, когда внешний стор изменяется.
subscribeдолжна вернуть функцию отписки (cleanup function), которая убирает подписку на изменения стора. Это как кнопка “отписаться” от рассылки – очень важно, чтобы React мог её нажать, когда компонент размонтируется или подписка меняется.
- Это функция, которая принимает колбэк
-
getSnapshot:- Это функция без аргументов, которая возвращает текущее значение внешнего стора.
- ОЧЕНЬ ВАЖНО:
getSnapshotдолжна возвращать иммутабельное значение. Если она будет возвращать новый объект/массив каждый раз, React будет думать, что стор изменился, и вызывать бесконечные ре-рендеры! Используй примитивы или стабильные ссылки на объекты. - React использует результат этой функции для сравнения и определения, нужно ли перерендерить компонент.
-
getServerSnapshot(опционально):- Эта функция также без аргументов и возвращает значение.
- Она используется только при серверном рендеринге (SSR).
- Её задача – предоставить начальное значение стора на сервере, чтобы избежать “гидратационного разрыва” (hydration mismatch), когда клиентский React-код видит другое состояние, чем то, что было отрендерено сервером.
- Если ты не используешь SSR, или внешний стор доступен только в браузере (например,
localStorage), можешь передатьundefinedили функцию, которая возвращает дефолтное значение.
🚀 Пример 1: Простой Глобальный Счётчик
Заголовок раздела «🚀 Пример 1: Простой Глобальный Счётчик»Давай создадим простейший глобальный стор-счётчик и подключим его к React с помощью useSyncExternalStore.
type Subscriber = () => void;
let currentCount = 0;const subscribers = new Set<Subscriber>();
// Функция, которая оповещает всех подписчиков об измененииfunction emitChange() { subscribers.forEach((subscriber) => subscriber());}
export const counterStore = { // Получаем текущее значение счётчика getCount(): number { return currentCount; },
// Увеличиваем счётчик и оповещаем подписчиков increment(): void { currentCount++; emitChange(); },
// Уменьшаем счётчик и оповещаем подписчиков decrement(): void { currentCount--; emitChange(); },
// Подписываемся на изменения. Возвращаем функцию отписки. subscribe(callback: Subscriber): () => void { subscribers.add(callback); return () => subscribers.delete(callback); // Важно: возвращаем функцию для отписки },};Теперь, создадим React-хук, использующий useSyncExternalStore:
import { useSyncExternalStore } from 'react';import { counterStore } from '../store/counterStore';
export function useCounter() { const count = useSyncExternalStore( counterStore.subscribe, // Функция подписки counterStore.getCount, // Функция получения текущего состояния () => 0 // Функция для SSR: начальное значение 0 );
const increment = counterStore.increment; const decrement = counterStore.decrement;
return { count, increment, decrement };}И используем его в компоненте:
import React from 'react';import { useCounter } from '../hooks/useCounter';
export function CounterComponent() { const { count, increment, decrement } = useCounter();
return ( <div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '8px' }}> <h3>Счётчик из Внешнего Стора</h3> <p>Текущее значение: <strong>{count}</strong></p> <button onClick={increment} style={{ marginRight: '10px' }}>Увеличить</button> <button onClick={decrement}>Уменьшить</button> </div> );}🌍 Пример 2: Синхронизация с localStorage
Заголовок раздела «🌍 Пример 2: Синхронизация с localStorage»localStorage – отличный кандидат для useSyncExternalStore, так как его изменения (даже из других вкладок) можно отслеживать через событие storage.
type LocalStorageKey = 'my-settings' | 'user-theme'; // Определим ключи, чтобы избежать опечаток
interface UserSettings { theme: 'dark' | 'light'; notificationsEnabled: boolean;}
const defaultSettings: UserSettings = { theme: 'light', notificationsEnabled: true,};
// Функция для чтения настроек из localStoragefunction readSettingsFromLocalStorage(): UserSettings { if (typeof window === 'undefined') { return defaultSettings; // На сервере localStorage недоступен } try { const item = window.localStorage.getItem('my-settings'); return item ? JSON.parse(item) : defaultSettings; } catch (error) { console.error("Error reading from localStorage:", error); return defaultSettings; }}
// Функция для записи настроек в localStoragefunction writeSettingsToLocalStorage(settings: UserSettings): void { if (typeof window !== 'undefined') { window.localStorage.setItem('my-settings', JSON.stringify(settings)); }}
// Создаем "сторовую" обёрткуexport const localStorageStore = { getSnapshot(): UserSettings { return readSettingsFromLocalStorage(); },
// Подписываемся на событие storage, которое срабатывает при изменении localStorage из других вкладок subscribe(callback: () => void): () => void { if (typeof window === 'undefined') { return () => {}; // На сервере нет событий storage } window.addEventListener('storage', callback); return () => window.removeEventListener('storage', callback); },
// Функция для обновления настроек setSettings(newSettings: Partial<UserSettings>): void { const currentSettings = this.getSnapshot(); const updatedSettings = { ...currentSettings, ...newSettings }; writeSettingsToLocalStorage(updatedSettings); // Принудительно вызываем оповещение, так как setItem не вызывает 'storage' для текущей вкладки // Если бы мы хотели, чтобы это событие срабатывало и для текущей вкладки, // пришлось бы эмитить его вручную или использовать более сложный паттерн. // Для useSyncExternalStore достаточно, что getSnapshot обновится при следующем рендере. // Но для других компонентов, которые могут использовать этот стор напрямую, // лучше было бы добавить emitChange(). Для простоты пока опустим. },
// Значение для SSR: всегда возвращаем дефолтные настройки getServerSnapshot(): UserSettings { return defaultSettings; },};И хук, использующий это:
import { useSyncExternalStore } from 'react';import { localStorageStore } from '../store/localStorageStore';import { UserSettings } from '../store/localStorageStore'; // Импортируем тип
export function useUserSettings() { const settings = useSyncExternalStore( localStorageStore.subscribe, localStorageStore.getSnapshot, localStorageStore.getServerSnapshot );
// Добавляем функцию для обновления настроек const updateSettings = (newSettings: Partial<UserSettings>) => { localStorageStore.setSettings(newSettings); };
return { settings, updateSettings };}Компонент:
import React from 'react';import { useUserSettings } from '../hooks/useUserSettings';
export function UserSettingsComponent() { const { settings, updateSettings } = useUserSettings();
const toggleTheme = () => { updateSettings({ theme: settings.theme === 'light' ? 'dark' : 'light' }); };
const toggleNotifications = () => { updateSettings({ notificationsEnabled: !settings.notificationsEnabled }); };
// Дополнительно: чтобы увидеть изменение темы сразу, можно менять body class React.useEffect(() => { document.body.className = settings.theme; }, [settings.theme]);
return ( <div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '8px', marginTop: '20px' }}> <h3>Настройки Пользователя (LocalStorage)</h3> <p>Тема: <strong>{settings.theme}</strong></p> <p>Уведомления: <strong>{settings.notificationsEnabled ? 'Включены' : 'Выключены'}</strong></p> <button onClick={toggleTheme} style={{ marginRight: '10px' }}> Сменить тему на {settings.theme === 'light' ? 'тёмную' : 'светлую'} </button> <button onClick={toggleNotifications}> {settings.notificationsEnabled ? 'Выключить' : 'Включить'} уведомления </button> </div> );}🐛 Типичные Ошибки и Решения
Заголовок раздела «🐛 Типичные Ошибки и Решения»-
getSnapshotвозвращает новый объект каждый раз:// ❌ Плохо: getSnapshot возвращает новый объект каждый разconst myStore = {_value: { count: 0 },getSnapshot() {return { ...this._value }; // Каждый раз новый объект! React будет видеть изменения.},// ...};// ✅ Хорошо: Возвращаем примитив или стабильную ссылкуconst myStore = {_value: 0,getSnapshot() {return this._value; // Примитив всегда стабилен},// ...};// Если нужно вернуть объект, убедитесь, что он изменяется иммутабельно// и getSnapshot возвращает ссылку только на новый объект после изменения.// Например, если _value меняется так: this._value = { count: 1 }// Тогда getSnapshot: return this._value; будет работать корректно.Решение: Всегда возвращай примитивы (
string,number,boolean) или стабильные ссылки на объекты/массивы. Если состояние стора – объект, и он изменяется,getSnapshotдолжна возвращать ссылку на новый объект, а не каждый раз создавать его копию. -
Забыта функция отписки в
subscribe:// ❌ Плохо: Нет функции отпискиconst myStore = {// ...subscribe(callback: () => void) {// Где-то здесь добавили callback в список слушателей, но не вернули функцию для его удаления// subscribers.add(callback);},};// ✅ Хорошо: Всегда возвращаем функцию для удаления подпискиconst myStore = {// ...subscribe(callback: () => void): () => void {subscribers.add(callback);return () => subscribers.delete(callback); // Вот она!},};Решение: Без функции отписки будут утечки памяти и нежелательное поведение, когда компонент уже неактивен, но продолжает слушать изменения.
🎯 Практика
Заголовок раздела «🎯 Практика»Время для самостоятельной работы, мой юный падаван!
-
Погода из Глобального Стора: Создай простой глобальный стор
weatherStore, который хранит текущую температуру и город. Реализуй методыsetTemperature(temp: number)иsetCity(city: string). Затем создай React-хукuseWeatherна базеuseSyncExternalStoreи компонентWeatherDisplayComponent, который отображает эту информацию и кнопки для её изменения. -
Статус Онлайн/Офлайн: Используй
useSyncExternalStoreдля создания хукаuseNetworkStatus, который отслеживает, находится ли пользователь онлайн или офлайн. Используй браузерные событияwindow.addEventListener('online', ...)иwindow.addEventListener('offline', ...)для подписки.getSnapshotдолжен возвращатьnavigator.onLine. Подумай, что вернетgetServerSnapshot. -
Вычисляемое Состояние из Нескольких Источников: Представь, что у тебя есть
userProfileStore(хранит имя и фамилию) иuserSettingsStore(хранит предпочитаемый язык). ИспользуйuseSyncExternalStoreдля создания одного хукаuseUserDisplayInfo, который возвращает строку “Привет, [Имя Фамилия]! Ваш язык: [Язык]”. Подсказка: ты можешь создать функциюcombineStoresили просто внутриgetSnapshotсчитывать данные из обоих сторов. Это продвинутое задание, требующее умения синхронизировать несколько внешних источников.
💡 Совет
Заголовок раздела «💡 Совет»useSyncExternalStore – это низкоуровневый, но очень мощный примитив. Обычно ты не будешь использовать его напрямую в каждом компоненте. Вместо этого, ты создашь на его основе свои кастомные, более высокоуровневые хуки (как useCounter или useUserSettings), которые уже будут предоставлять удобный API для твоих компонентов. Это разделение ответственности делает код чище и проще в поддержке. Думай о нем как о фундаменте, на котором ты строишь свои архитектурные решения для работы с внешним состоянием.
🔗 Полезные ссылки
Заголовок раздела «🔗 Полезные ссылки»Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: