Внешние сторы: useSyncExternalStore, подписки-селекторы и tearing
useSyncExternalStore — контракт чтения состояния, которым React не владеет: subscribe + кешированный getSnapshot, с проверками tearing. Zustand строит на нём селекторы — без провайдера, ре-рендер только при изменении вашего среза — и бьёт контекст на частых записях.
У трейдингового дашборда был баг, который не доказывался скриншотами и не скриптовался в QA: изредка итог в шапке портфеля не равнялся сумме видимых строк. Пока кто-то успевал кликнуть, всё снова было верно. Самописный хук стора — useEffect для подписки, useState для зеркала значения — работал три года. Изменилось вот что: апгрейд на React 18 и startTransition вокруг тяжёлого фильтра. Рендеры стали прерываемыми: шапка читала стор в начале длинного прохода рендера, тик цены мутировал стор посреди рендера, и строки читали уже новое значение, когда рендер возобновился. Один рендер — две версии истины: tearing. Скриншот в итоге прислал клиент-фанат Bloomberg с монитором 144 Гц. Лечением стало удаление сорока строк кода подписки ради одного вызова useSyncExternalStore — контракта React ровно для этой проблемы: он сверяет снапшот после рендеринга и ре-рендерит синхронно, если стор уехал из-под него. Баг не воспроизводился, потому что требовал проиграть гонку, о существовании которой никто не знал.
К концу урока вы поймёте, почему апгрейд до React 18 может молча сломать хук стора, работавший три года, — и что именно API-контракт требует для защиты от этого.
Контракт: subscribe, getSnapshot и проверка на tearing
Состояние, которым владеет React (useState, useReducer), версионируется вместе с fiber-деревом, поэтому конкурентный рендеринг никогда не увидит две версии за один проход. У состояния, живущего вне React — модульного синглтона, кеша WebSocket, navigator.onLine, — такой гарантии нет: прерываемый рендер может растянуться поверх мутации, и компоненты, отрендеренные до и после неё, разойдутся в одном закоммиченном кадре. Это tearing (разрыв — когда один рендер-кадр показывает две разные версии одних и тех же данных), и useSyncExternalStore — контракт, который его предотвращает:
const value = useSyncExternalStore(
subscribe, // (callback) => unsubscribe; звать callback на каждое изменение стора
getSnapshot, // () => текущее значение; ОБЯЗАН возвращать кеш, пока ничего не изменилось
getServerSnapshot // опционально: значение для SSR + гидратации
);subscribe регистрирует в вашем сторе собственный колбэк React и возвращает очистку. getSnapshot возвращает текущее значение — и обязан возвращать ту же ссылку, пока стор реально не изменился, потому что React зовёт его многократно и решает по Object.is, сдвинулось ли что-то. Верните свежий объект на каждый вызов (() => ({ ...state })) — и React увидит вечно меняющийся стор: печально известная ошибка «The result of getSnapshot should be cached» и бесконечный цикл ре-рендеров. Анти-tearing-механизм: когда обновление стора приходит во время неблокирующего рендера, React перепроверяет снапшоты и заставляет конфликтующее обновление отрендериться синхронно, жертвуя time-slicing ради согласованности. У старого хука с подпиской через useEffect был и второй изъян помимо tearing: обновления между рендером и подпиской эффекта молча терялись — uSES закрывает и эту щель.
Zustand: подписки-селекторы поверх контракта
useSyncExternalStore намеренно низкоуровневый — один стор, подписка на значение целиком. Zustand — то, как этот паттерн выглядит в виде продукта. Стор создаётся вне React как синглтон уровня модуля — без провайдера, без контекста, импортируем откуда угодно, включая не-React-код:
const useCartStore = create((set, get) => ({
items: [],
promo: null,
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));
// Компонент подписывается на СРЕЗ через селектор:
function CartBadge() {
const count = useCartStore((s) => s.items.length);
return <span>{count}</span>; // ре-рендер ТОЛЬКО при изменении items.length
}Механизм — подписка-селектор: хук прогоняет ваш селектор по снапшоту и ре-рендерит компонент, только когда изменился вывод селектора (Object.is по умолчанию; кастомное равенство вроде shallow — по желанию). Запись промокода ре-рендерит ноль компонентов, выбравших только items.length. Сравните с контекстом: каждый потребитель ре-рендерится на любую смену идентичности значения, точка. Классическое самострельное ранение — селектор, возвращающий свежий объект: useCartStore((s) => ({ a: s.a, b: s.b })) — новая ссылка на каждый снапшот, ре-рендер на каждую запись в стор, точность молча исчезла (лечение: два селектора или shallow-равенство).
Когда внешний стор бьёт контекст — и когда состоянию надо обойти React вовсе
Прежде чем тянуться к внешнему стору, спросите: сколько потребителей ре-рендерится на запись, и как часто эти записи происходят? Это не вопрос моды; за каждой стороной механизм. Частые записи: контекст переуведомляет каждого потребителя на запись; стор уведомляет только совпавшие селекторы — при 20 записях в секунду и 50 потребителях это 1 000 рендеров компонентов в секунду против горстки. Доступ сквозь дерево: стор — это импорт модуля; обработчики событий, лоадеры роутов, аналитика и код вне иерархии провайдеров могут читать и писать его; контекст требует находиться под провайдером. Инструменты: middleware (persist, immer, devtools с историей экшенов) подключаются на границе стора. Контекст по-прежнему выигрывает для редко пишущихся, скоупо-образных значений — тема, локаль, текущий пользователь, — где иерархия провайдеров и есть фича, а зависимость — лишний вес.
▸lesson.inset.how
Транзиентные обновления — сеньорский ход для данных на 60 Гц: подписывайтесь императивно и пропускайте рендеринг целиком. useCartStore.subscribe(...) (или middleware subscribeWithSelector у zustand) внутри эффекта, с записью в ref или прямой мутацией DOM-узла — позиция курсора, живой график, призрак перетаскивания — обновляется с частотой кадров при нулевом reconciliation. React рендерит компонент один раз; пиксели ведёт стор. Эмпирическое правило: если значение меняется быстрее, чем пользователь способен его прочитать, ему, скорее всего, вообще не место в состоянии React.
getSnapshot, реализованный как () => ({ ...store.state }), вызывает ошибку «The result of getSnapshot should be cached» и бесконечный цикл. Каков механизм?
Дашборд получает ~20 записей в стор в секунду, подписано ~50 компонентов, каждому важен свой срез. Почему стор в стиле zustand здесь принципиально обгоняет контекст?
- 01Объясните tearing: почему самописная подписка через useEffect + useState может показать две версии стора в одном кадре под конкурентным React, и как useSyncExternalStore это предотвращает?
- 02Пройдите решение: контекст против внешнего стора в стиле zustand против транзиентной подписки — какой механизм делает каждый инструмент правильным и для какого состояния?
Внешнее состояние — всё, что живёт вне useState/useReducer, — ломает инвариант, который React иначе гарантирует: один проход рендера — одна версия каждого значения. Конкурентный рендеринг делает поломку видимой как tearing: прерываемый рендер растягивается поверх записи в стор, и компоненты, закоммиченные в одном кадре, расходятся. useSyncExternalStore — контракт, возвращающий согласованность: subscribe даёт React колбэк изменений в ваш стор; getSnapshot возвращает текущее значение и обязан возвращать ту же ссылку до настоящего изменения, потому что React сравнивает снапшоты через Object.is — свежий объект на вызов означает ошибку кешированного снапшота и бесконечный цикл. Когда запись приходит посреди рендера, React замечает расхождение и ре-рендерит синхронно, сознательно жертвуя time-slicing ради корректности; заодно закрывается щель потерянных обновлений эффектных подписок. Zustand — этот контракт как продукт: сторы — синглтоны модуля, созданные вне React — без провайдера, импортируемые из обработчиков и не-React-кода, — а компоненты подписываются селекторами и ре-рендерятся только при изменении выбранного вывода. Эта точность селекторов — количественный аргумент против контекста для частых записей: уведомлять совпавшие срезы, а не каждого потребителя. За контекстом остаются редко пишущиеся скоупо-образные задачи. А для данных на частоте кадров пропускайте рендеринг целиком: транзиентные подписки пишут в ref или DOM из императивного subscribe — стор ведёт пиксели, React рендерит один раз. Теперь, когда вы увидите дашборд, где несвязанные компоненты мигают на каждый тик, вы первым делом проверите селекторы — и вспомните, что классический самострел — это селектор, возвращающий новый объект на каждый вызов.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.