useEffect и ref в React 19: как StrictMode подставляет в production и как это починить
StrictMode в React 19 стал строже — двойной mount-unmount в dev ловит утечки, но порождает новую ловушку: код, который работает локально, ломается на проде. Чаще всего страдают хуки, где
Типичная ошибка: полагаться на
Паттерн: защита через проверку node
Храни ссылку на DOM-узел на момент монтирования и проверяй её перед использованием:
Здесь
Идемпотентность cleanup
Любой cleanup должен быть безопасен при многократном вызове — например, через флаги:
Так даже если StrictMode вызовет cleanup несколько раз, ничего не сломается.
Типичная ошибка: таймеры и subscriptions
Перед релизом пройдись по всем
- каждый cleanup явно отписывается
- нет доступа к
- cleanup без побочных эффектов при повторном вызове
Вывод: StrictMode в React 19 — не баг, а фича: если cleanup выдерживает двойной mount, production не удивит тебя утечками в самый неподходящий момент.
StrictMode в React 19 стал строже — двойной mount-unmount в dev ловит утечки, но порождает новую ловушку: код, который работает локально, ломается на проде. Чаще всего страдают хуки, где
ref.current используется в cleanup без защиты от null.Типичная ошибка: полагаться на
ref.current в return-функции useEffect. В dev второй mount может дать null, а в production — рейс-кондишн, который не воспроизвести локально.Паттерн: защита через проверку node
Храни ссылку на DOM-узел на момент монтирования и проверяй её перед использованием:
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(() => { /* ... */ });
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [ref]);
Здесь
node захватывается на момент первого mount, а cleanup отвязывается от того же узла. Проблема двойного вызова устраняется.Идемпотентность cleanup
Любой cleanup должен быть безопасен при многократном вызове — например, через флаги:
useEffect(() => {
let cleanupCalled = false;
const handler = () => { /* ... */ };
addEventListener('scroll', handler);
return () => {
if (cleanupCalled) return;
cleanupCalled = true;
removeEventListener('scroll', handler);
};
}, []);Так даже если StrictMode вызовет cleanup несколько раз, ничего не сломается.
Типичная ошибка: таймеры и subscriptions
setInterval без сохранения id или addEventListener, который не отписан — самые частые виновники. В production они создают дубли, которые не воспроизводятся в dev без ручной проверки.Перед релизом пройдись по всем
useEffect:- каждый cleanup явно отписывается
- нет доступа к
ref.current без проверки на null- cleanup без побочных эффектов при повторном вызове
Вывод: StrictMode в React 19 — не баг, а фича: если cleanup выдерживает двойной mount, production не удивит тебя утечками в самый неподходящий момент.
👍12❤2👎1
CSS Layers и @layer: организация специфичности в крупных React-проектах без magic-переопределений
Каждый, кто работал над большим React-приложением, сталкивался с хаосом специфичности: один файл переопределяет стили другого, появляются !important, глубокие селекторы. Решение - CSS Layers (@layer) - явный контроль приоритетов между слоями стилей, независимо от селекторов.
Четкая иерархия слоев
Определяем слои: base (сброс, токены), components (UI-кит), overrides (специфичные правки). Даже если внутри overrides селектор слабее - стили из этого слоя перекроют components. В production это избавляет от каскадных конфликтов при подключении CSS Modules в React-компонентах.
Изоляция библиотек
Сторонние UI-библиотеки (Ant Design, MUI) кладем в свой слой. Их стили не "пробивают" ваши кастомные, если поместили свой слой выше. Например, библиотечные кнопки с высокоспецифичными селекторами перестанут ломать дизайн-систему.
Никаких !important
Раньше для переопределения библиотечных стилей требовалось увеличивать специфичность. Теперь - меняем порядок слоев. Типичная ошибка: забыть задать порядок слоев первой @layer директивой без правил - иначе слои применяются в порядке появления.
Пример структуры:
В React-сборке (Vite, CRA) подключаем global.css в index.tsx, а компонентные стили (CSS Modules / styled-components) "ложатся" в нужный слой через @layer. При дублировании имени слоя в разных файлах - они объединяются в один слой, что упрощает code review.
Важно:
- @layer - нативная CSS-фича, поддержка браузеров ~96%
- Порядок слоев задается один раз (первая @layer директива без правил)
- Ошибка: пытаться переопределить стили через @import без явного указания слоя
Вывод: Предсказуемая каскадность без !important, глубоких селекторов и code review-сражений - попробуйте на следующем рефакторинге.
Каждый, кто работал над большим React-приложением, сталкивался с хаосом специфичности: один файл переопределяет стили другого, появляются !important, глубокие селекторы. Решение - CSS Layers (@layer) - явный контроль приоритетов между слоями стилей, независимо от селекторов.
Четкая иерархия слоев
Определяем слои: base (сброс, токены), components (UI-кит), overrides (специфичные правки). Даже если внутри overrides селектор слабее - стили из этого слоя перекроют components. В production это избавляет от каскадных конфликтов при подключении CSS Modules в React-компонентах.
Изоляция библиотек
Сторонние UI-библиотеки (Ant Design, MUI) кладем в свой слой. Их стили не "пробивают" ваши кастомные, если поместили свой слой выше. Например, библиотечные кнопки с высокоспецифичными селекторами перестанут ломать дизайн-систему.
Никаких !important
Раньше для переопределения библиотечных стилей требовалось увеличивать специфичность. Теперь - меняем порядок слоев. Типичная ошибка: забыть задать порядок слоев первой @layer директивой без правил - иначе слои применяются в порядке появления.
Пример структуры:
/* global.css */
@layer base, components, overrides;
@layer base {
:root { --color-primary: blue; }
*, *::before, *::after { box-sizing: border-box; }
}
@layer components {
.btn { background: var(--color-primary); }
}
@layer overrides {
.btn.special { background: red; }
}
В React-сборке (Vite, CRA) подключаем global.css в index.tsx, а компонентные стили (CSS Modules / styled-components) "ложатся" в нужный слой через @layer. При дублировании имени слоя в разных файлах - они объединяются в один слой, что упрощает code review.
Важно:
- @layer - нативная CSS-фича, поддержка браузеров ~96%
- Порядок слоев задается один раз (первая @layer директива без правил)
- Ошибка: пытаться переопределить стили через @import без явного указания слоя
Вывод: Предсказуемая каскадность без !important, глубоких селекторов и code review-сражений - попробуйте на следующем рефакторинге.
👀5👎2
CSS без Flash of Unstyled Content: как балансировать критический CSS и отложенные стили в рантайме
Критический CSS инлайн — First Paint за 200 мс, но тяжелый компонент с динамическим
Runtime-подход через import()
Динамический
Компонент не рендерится, пока CSS не загружен. Критический CSS уже инлайн — First Paint мгновенный. Остальные стили подгружаются асинхронно, не блокируя поток.
Почему import() эффективнее link с media
Типичная ошибка и trade-offs
Для SSR обязательно синхронизировать рендеринг с критическим CSS. Иначе гидратация дернется — FOUC вернется при клиентском монтировании. Если асинхронных CSS много, начнется network contention. Решение — расставить приоритеты через
Вывод: Асинхронный
Критический CSS инлайн — First Paint за 200 мс, но тяжелый компонент с динамическим
import('Modal.css') ломает картину. Пользователь видит сырой HTML на долю секунды. FOUC возвращается, если не управлять загрузкой в runtime.Runtime-подход через import()
Динамический
import() на уровне компонента ждет загрузку CSS перед монтированием. Без костылей с media="print" или preload, где поведение не всегда очевидно.const Modal = () => {
const [cssLoaded, setCssLoaded] = useState(false);
useEffect(() => {
import('./Modal.css').then(() => setCssLoaded(true));
}, []);
if (!cssLoaded) return null;
return <div>...</div>;
};Компонент не рендерится, пока CSS не загружен. Критический CSS уже инлайн — First Paint мгновенный. Остальные стили подгружаются асинхронно, не блокируя поток.
Почему import() эффективнее link с media
import() работает через бандлер. Tree-shaking для CSS нативен — никаких костылей с переключением media после загрузки. Импорт резолвится как обычный модуль. Предсказуемо и чисто.Типичная ошибка и trade-offs
Для SSR обязательно синхронизировать рендеринг с критическим CSS. Иначе гидратация дернется — FOUC вернется при клиентском монтировании. Если асинхронных CSS много, начнется network contention. Решение — расставить приоритеты через
preload для самых критичных отложенных компонентов, чтобы не портить LCP.Вывод: Асинхронный
import() CSS в паре с инлайн-критическим — рабочий способ избежать FOUC без блокировки рендеринга, но требует управления приоритетами загрузки для production-надежности.👍5❤1👎1
Оптимистичные обновления в Next.js RSC — штука красивая, но с подводными: как не сломать UX на границе сервера и клиента
Смотри, идея простая: ты дергаешь серверный экшен, а UI обновляется сразу, не дожидаясь ответа. Раньше это требовало дублирования логики на клиенте и сервере, сегодня, с
Проблема: потеря данных при параллельных мутациях
Если пользователь быстро отправляет два запроса подряд, optimistic update может перезаписать данные, еще не подтвержденные первым. Чтобы этого избежать, используйте идемпотентные ключи и очередь экшенов.
Trade-off: Suspense как ловушка для пользователя
Обернув список в
Если сервер не ответил за 3 секунды, принудительно откатывайте оптимистичное состояние и показывайте ошибку.
Где пригодится на практике
* Лайки и комментарии в соцсетях.
* Корзина интернет-магазина.
* Инлайн-редактирование полей профиля.
* Drag-and-drop с сохранением порядка.
Ключевое: сервер остается source of truth, клиент не раздувается лишним состоянием, UX перестает тормозить на ровном месте.
Вывод: Оптимистичные обновления эффективны только при явной синхронизации optimistic state с серверным ответом и грамотной обработке ошибок через Suspense-границы — иначе пользователь видит призрачные данные, которых не существует.
Смотри, идея простая: ты дергаешь серверный экшен, а UI обновляется сразу, не дожидаясь ответа. Раньше это требовало дублирования логики на клиенте и сервере, сегодня, с
useOptimistic из React 19 и Suspense-границами, можно сделать чисто. Но на практике многие забывают согласовать optimistic state с серверным ответом — и получают рассинхрон.Проблема: потеря данных при параллельных мутациях
Если пользователь быстро отправляет два запроса подряд, optimistic update может перезаписать данные, еще не подтвержденные первым. Чтобы этого избежать, используйте идемпотентные ключи и очередь экшенов.
const action = async (prevState, formData) => {
const tempId = crypto.randomUUID();
addOptimistic({ id: tempId, text: formData.get('text'), pending: true });
const result = await api.update(formData);
if (!result.ok) {
rollbackOptimistic(tempId); // кастомный rollback
return { error: result.error };
}
// Сервер сам управляет состоянием
};Trade-off: Suspense как ловушка для пользователя
Обернув список в
<Suspense> при сбое, вы рискуете показать бесконечный fallback. Решение — задать минимальный таймаут для отката:<Suspense fallback={<Loading />}>
<List items={optimisticState} />
</Suspense>Если сервер не ответил за 3 секунды, принудительно откатывайте оптимистичное состояние и показывайте ошибку.
Где пригодится на практике
* Лайки и комментарии в соцсетях.
* Корзина интернет-магазина.
* Инлайн-редактирование полей профиля.
* Drag-and-drop с сохранением порядка.
Ключевое: сервер остается source of truth, клиент не раздувается лишним состоянием, UX перестает тормозить на ровном месте.
Вывод: Оптимистичные обновления эффективны только при явной синхронизации optimistic state с серверным ответом и грамотной обработке ошибок через Suspense-границы — иначе пользователь видит призрачные данные, которых не существует.
👍3👀3❤2👎1
useSyncExternalStore против ручных подписок в Concurrent React: как не допустить tearing UI
В Concurrent React классическая проблема — tearing, или разрыв UI, при чтении внешнего стора во время рендера. Многие разработчики по инерции используют ручные подписки через
Проблема ручных подписок
Ручная подписка выглядит так:
Типичная ошибка: между
Решение: useSyncExternalStore
Начиная с React 18, используйте
Ключевой момент:
Практический совет
Если ваш проект на React 18+, ставьте
Предупреждение
Не используйте ручные подписки в среде Concurrent React, если не уверены, что стор иммутабельно меняет всё дерево за один тик. На маленьких проектах tearing может не проявляться, но на продакшене с тяжелыми компонентами он вылезает как баг, который невозможно отловить без инструментов вроде React DevTools profiling.
Вывод: Если вы не готовы к прерываниям рендера в Concurrent Mode — используйте
В Concurrent React классическая проблема — tearing, или разрыв UI, при чтении внешнего стора во время рендера. Многие разработчики по инерции используют ручные подписки через
useEffect, забывая, что в Concurrent Mode React может прервать рендер и затем продолжить с устаревшим состоянием.Проблема ручных подписок
Ручная подписка выглядит так:
function useStore(store) {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsub = store.subscribe(() => setState(store.getState()));
return unsub;
}, [store]);
return state;
}Типичная ошибка: между
useState(store.getState()) и подпиской в useEffect может произойти параллельное обновление стора. В Concurrent Mode React перерисовывает UI в несколько проходов, и если стор изменился между ними — вы получите неконсистентный интерфейс. Особенно остро это проявляется на продакшене с тяжелыми дашбордами, например, при агрессивном кэшировании или WebSocket-стримах.Решение: useSyncExternalStore
Начиная с React 18, используйте
useSyncExternalStore:import { useSyncExternalStore } from 'react';
function useStore(store) {
const state = useSyncExternalStore(
store.subscribe,
() => store.getState()
);
return state;
}Ключевой момент:
getSnapshot вызывается синхронно при каждом рендере. Если снапшот изменился — React перезапускает рендер, гарантируя консистентность. Это закрывает гонки, которые сложно воспроизвести локально.Практический совет
Если ваш проект на React 18+, ставьте
useSyncExternalStore по умолчанию для всех внешних сторов (Redux, Zustand, MobX). Даже если сейчас вы не используете Concurrent Features, переход на них позже не сломает UI. Ложная абстракция здесь окупается надежностью.Предупреждение
Не используйте ручные подписки в среде Concurrent React, если не уверены, что стор иммутабельно меняет всё дерево за один тик. На маленьких проектах tearing может не проявляться, но на продакшене с тяжелыми компонентами он вылезает как баг, который невозможно отловить без инструментов вроде React DevTools profiling.
Вывод: Если вы не готовы к прерываниям рендера в Concurrent Mode — используйте
useSyncExternalStore как единственный корректный способ синхронизации внешнего состояния с React.👍5❤4
:has() в продакшене: три сценария, которые меняют подход к CSS
CSS-селектор
1. State-селекторы без JS
Раньше, чтобы подсветить карточку при наведении на кнопку внутри, приходилось лепить JS-обработчики. Сейчас можно одной строкой:
Работает с
2. Form-validation UX без классов
Типичная задача: инпут с ошибкой, сообщение об ошибке под ним. Раньше через JS вешали класс на родителя. Теперь:
Условие читается как "если после поля есть блок с ошибкой и он не пустой — крась рамку в красный". Всё реактивно, стили обновляются автоматически. Это упрощает код и устраняет задержки на установку классов.
3. Адаптивные layout’ы без медиавыражений
Вместо ширины экрана смотрим на содержимое. Например, если карточек меньше трёх — оставляем одну колонку, три и больше — две:
Или сайдбар пустой — контент на всю ширину:
Для простых кейсов заменяет
Trade-offs и предупреждение
Поддержка ~92% глобально. Если нужен fallback для старых браузеров — оставляй JS-классы как запасной вариант.
Вывод:
CSS-селектор
:has() наконец-то стабилен в браузерах и реально востребован в современных интерфейсах. Он позволяет стилизовать родителя по состоянию потомка, что раньше требовало JavaScript или костылей с классами. Частая ошибка — использовать его везде, не оценивая производительность и поддержку.1. State-селекторы без JS
Раньше, чтобы подсветить карточку при наведении на кнопку внутри, приходилось лепить JS-обработчики. Сейчас можно одной строкой:
.card:has(.action-btn:hover) {
box-shadow: 0 0 0 2px var(--accent);
transform: scale(1.02);
}Работает с
:focus-within, :checked и любыми псевдоклассами. Полезно для интерактивных списков, где нужно визуально выделять активный элемент без мутаций DOM.2. Form-validation UX без классов
Типичная задача: инпут с ошибкой, сообщение об ошибке под ним. Раньше через JS вешали класс на родителя. Теперь:
.field:has(+ .error-msg:not(:empty)) input {
border-color: var(--error);
}Условие читается как "если после поля есть блок с ошибкой и он не пустой — крась рамку в красный". Всё реактивно, стили обновляются автоматически. Это упрощает код и устраняет задержки на установку классов.
3. Адаптивные layout’ы без медиавыражений
Вместо ширины экрана смотрим на содержимое. Например, если карточек меньше трёх — оставляем одну колонку, три и больше — две:
.grid:has(.card:nth-child(3)) {
grid-template-columns: repeat(2, 1fr);
}Или сайдбар пустой — контент на всю ширину:
.layout:has(.sidebar:empty) .content {
grid-column: 1 / -1;
}Для простых кейсов заменяет
@container, но без поддержки контекста размера. Подходит для вложенных списков или вариативных форм.Trade-offs и предупреждение
:has() — дорогой селектор. На списках из тысяч элементов с частыми перерисовками (например, drag-and-drop или live-обновления) он может тормозить. Всегда тестируй производительность в DevTools Performance.Поддержка ~92% глобально. Если нужен fallback для старых браузеров — оставляй JS-классы как запасной вариант.
Вывод:
:has() переносит часть UI-логики из JS в CSS, но используй его осознанно: только для статичных или редко изменяющихся структур, иначе рискуешь получить фреймовую задержку.❤3👍2👀1
useDeferredValue React 19: как Scheduler меняет правила игры с частыми обновлениями
Раньше задача управления частыми обновлениями в React решалась через lodash.debounce или кастомные хуки с риском протухших замыканий. В production это приводило к потере ввода, лагам интерфейса и спаму сетевых запросов. React 19 меняет подход: useDeferredValue в связке с Scheduler сохраняет все обновления, но расставляет приоритеты.
Как это работает
В отличие от debounce, который отбрасывает лишние вызовы, useDeferredValue не теряет данные. Он разделяет обновления на срочные (ввод текста) и фоновые (тяжелые вычисления). React сам решает, когда выполнить вторые, используя механизм приоритетов Scheduler. По умолчанию задержка до 300 мс, регулируемая через Scheduler.unstable_shouldYield.
Пример для поиска с тяжелой фильтрацией:
Здесь query обновляется мгновенно — инпут не тормозит. A deferredQuery React подтягивает позже, сохраняя отзывчивость.
Где это критически важно
useDeferredValue незаменим, когда все изменения важны и их нельзя терять. Типичный production-сценарий: поиск с автодополнением, где каждое нажатие клавиши должно быть учтено, но фильтрация результатов — тяжелая операция. Или рендер списка с вложенными компонентами при скролле.
Типичная ошибка
Начинающие разработчики применяют useDeferredValue для контроля сетевых запросов. Это не работает: если пользователь кликает каждые 100 мс, на сервер полетит столько же запросов. useDeferredValue не сбрасывает их и не отменяет — он только откладывает рендер. Для API-вызовов все еще нужен debounce.
Когда debounce остается
Старый добрый debounce незаменим в трех случаях: строгий тайминг после последнего действия (отправка формы, автосохранение), сброс предыдущих запросов, и контроль количества вызовов API. useDeferredValue для этого не подходит.
Вывод: useDeferredValue — это не замена debounce, а новый инструмент для UI-оптимизаций внутри компонента, который требует осознанного выбора между потерей данных и отзывчивостью.
Раньше задача управления частыми обновлениями в React решалась через lodash.debounce или кастомные хуки с риском протухших замыканий. В production это приводило к потере ввода, лагам интерфейса и спаму сетевых запросов. React 19 меняет подход: useDeferredValue в связке с Scheduler сохраняет все обновления, но расставляет приоритеты.
Как это работает
В отличие от debounce, который отбрасывает лишние вызовы, useDeferredValue не теряет данные. Он разделяет обновления на срочные (ввод текста) и фоновые (тяжелые вычисления). React сам решает, когда выполнить вторые, используя механизм приоритетов Scheduler. По умолчанию задержка до 300 мс, регулируемая через Scheduler.unstable_shouldYield.
Пример для поиска с тяжелой фильтрацией:
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
return heavySearch(deferredQuery);
}, [deferredQuery]);Здесь query обновляется мгновенно — инпут не тормозит. A deferredQuery React подтягивает позже, сохраняя отзывчивость.
Где это критически важно
useDeferredValue незаменим, когда все изменения важны и их нельзя терять. Типичный production-сценарий: поиск с автодополнением, где каждое нажатие клавиши должно быть учтено, но фильтрация результатов — тяжелая операция. Или рендер списка с вложенными компонентами при скролле.
Типичная ошибка
Начинающие разработчики применяют useDeferredValue для контроля сетевых запросов. Это не работает: если пользователь кликает каждые 100 мс, на сервер полетит столько же запросов. useDeferredValue не сбрасывает их и не отменяет — он только откладывает рендер. Для API-вызовов все еще нужен debounce.
Когда debounce остается
Старый добрый debounce незаменим в трех случаях: строгий тайминг после последнего действия (отправка формы, автосохранение), сброс предыдущих запросов, и контроль количества вызовов API. useDeferredValue для этого не подходит.
Вывод: useDeferredValue — это не замена debounce, а новый инструмент для UI-оптимизаций внутри компонента, который требует осознанного выбора между потерей данных и отзывчивостью.
👍6❤3👀2
WeakRef и FinalizationRegistry: как GC чистит тяжелые ресурсы за вас
Вы тянете в React-компонент кэш изображений, WebGL-текстуру или WebAssembly-модуль. Ручная очистка в return useEffect работает только если компонент размонтируется. Если ссылка утекла в замыкание — память забивается. Слабые ссылки решают это без ручного управления.
WeakRef — слабая ссылка без блокировки GC
WeakRef хранит ссылку на объект, но не мешает сборщику мусора удалить его. Когда объект собран — deref() возвращает undefined. Вы не блокируете память, а только следите, жив ли объект.
FinalizationRegistry — колбэк после сборки
Регистрируете callback, который вызывается строго после того, как GC собрал объект. Это для освобождения внешних ресурсов: закрыть сокет, отпустить текстуру, отменить подписку.
Пример: самовозобновляющийся кэш
Типичная ошибка и предупреждение
GC не детерминирован — FinalizationRegistry может вызваться через минуту, час или никогда (если браузер упадет). Не полагайтесь на него для критичных по времени очисток. Используйте только для тяжелых объектов: WebAssembly, бинарные данные, WebGL. Для простых JSON или строк — обычный Map с return useEffect, так быстрее и надежнее.
Вывод:
WeakRef и FinalizationRegistry дают инструмент для автоматической очистки памяти без микроменеджмента в useEffect, но требуют понимания, что GC не мгновенный.
Вы тянете в React-компонент кэш изображений, WebGL-текстуру или WebAssembly-модуль. Ручная очистка в return useEffect работает только если компонент размонтируется. Если ссылка утекла в замыкание — память забивается. Слабые ссылки решают это без ручного управления.
WeakRef — слабая ссылка без блокировки GC
WeakRef хранит ссылку на объект, но не мешает сборщику мусора удалить его. Когда объект собран — deref() возвращает undefined. Вы не блокируете память, а только следите, жив ли объект.
FinalizationRegistry — колбэк после сборки
Регистрируете callback, который вызывается строго после того, как GC собрал объект. Это для освобождения внешних ресурсов: закрыть сокет, отпустить текстуру, отменить подписку.
Пример: самовозобновляющийся кэш
const cache = new Map<string, WeakRef<object>>();
const registry = new FinalizationRegistry((key: string) => {
cache.delete(key);
});
function useHeavyData(key: string) {
const dataRef = useRef<object | null>(null);
useEffect(() => {
const cached = cache.get(key)?.deref();
if (cached) {
dataRef.current = cached;
return;
}
const newData = computeHeavy(key);
cache.set(key, new WeakRef(newData));
registry.register(newData, key);
dataRef.current = newData;
}, [key]);
return dataRef.current;
}
Типичная ошибка и предупреждение
GC не детерминирован — FinalizationRegistry может вызваться через минуту, час или никогда (если браузер упадет). Не полагайтесь на него для критичных по времени очисток. Используйте только для тяжелых объектов: WebAssembly, бинарные данные, WebGL. Для простых JSON или строк — обычный Map с return useEffect, так быстрее и надежнее.
Вывод:
WeakRef и FinalizationRegistry дают инструмент для автоматической очистки памяти без микроменеджмента в useEffect, но требуют понимания, что GC не мгновенный.
👍5👀2
Тысяча SVG-иконок ломают FPS. Что делать с панелью мониторинга?
Каждый
Виртуализация для списков
React-window или Intersection Observer рендерят только то, что в окне просмотра. Это спасает для скроллящихся панелей, но если у вас анимация на каждом элементе (пульсация, вращение), DOM все равно пересчитывает layout для видимых узлов. Предупреждение: виртуализация бесполезна, когда все элементы находятся в области видимости.
OffscreenCanvas для статики
Рисуйте SVG в Web Worker через
Клонирование узлов для повторяющихся шаблонов
Сценарий: панель с 50 датчиками, каждый — повторяющаяся группа из трех SVG. Не делайте innerHTML для каждого. Загрузите шаблон один раз, распарсьте через
Глубокое клонирование быстрее парсинга строки на каждый элемент. Минус: если SVG разные — не сработает.
Вывод: Выбор стратегии зависит от сценария: для прокрутки — виртуализация, для повторяющихся иконок — importNode, для динамической отрисовки множества элементов — OffscreenCanvas с Path2D.
Каждый
<svg> в DOM — это полноценный узел с собственным жизненным циклом, стилями и вычислением layout. При 1000 таких узлов память растет, FPS падает, а скролл превращается в слайд-шоу. Частая ошибка — пытаться решить проблему через один инструмент, не учитывая сценарий использования. Вот три подхода с реальными trade-offs.Виртуализация для списков
React-window или Intersection Observer рендерят только то, что в окне просмотра. Это спасает для скроллящихся панелей, но если у вас анимация на каждом элементе (пульсация, вращение), DOM все равно пересчитывает layout для видимых узлов. Предупреждение: виртуализация бесполезна, когда все элементы находятся в области видимости.
OffscreenCanvas для статики
Рисуйте SVG в Web Worker через
OffscreenCanvas. Transferable объект передается без копирования памяти. На выходе — bitmap для отрисовки. Идеально для статичных иконок: никакого DOM, никаких стилей. Но кликнуть по такому SVG не выйдет, и анимацию через CSS не повесить. Если нужен hover — придется самостоятельно считать координаты.Клонирование узлов для повторяющихся шаблонов
Сценарий: панель с 50 датчиками, каждый — повторяющаяся группа из трех SVG. Не делайте innerHTML для каждого. Загрузите шаблон один раз, распарсьте через
DOMParser и клонируйте через document.importNode:const template = await fetch('sensor.svg').then(r => r.text());
const parser = new DOMParser();
const doc = parser.parseFromString(template, 'image/svg+xml');
const clone = document.importNode(doc.documentElement, true);
container.appendChild(clone);Глубокое клонирование быстрее парсинга строки на каждый элемент. Минус: если SVG разные — не сработает.
Вывод: Выбор стратегии зависит от сценария: для прокрутки — виртуализация, для повторяющихся иконок — importNode, для динамической отрисовки множества элементов — OffscreenCanvas с Path2D.
👍5👎2👀2
display: contents и React: когда переиспользование контейнера ломает семантику, разметку и анимации — и как чинить
Универсальные компоненты-обертки вроде
Семантика и роль контейнера
Проблема:
Но если у контейнера есть
Анимации и неожиданное поведение
Где не работает и trade-offs
* Табличные элементы (
* Корневые элементы (
* ARIA-атрибуты на контейнере пропадают для доступности: если нужен
Практический совет
В production используй
Это уменьшает дублирование стилей и сохраняет семантику. Но анимированные элементы всегда оборачивай дополнительно.
Вывод:
Универсальные компоненты-обертки вроде
Box или Card — стандарт production-кода. Но они часто нарушают семантику HTML, ломают flex/grid layout и делают DOM-дерево невалидным. Решение — display: contents, но оно не так безобидно, как кажется.Семантика и роль контейнера
Проблема:
<ul> внутри <div> рендерит <li> не прямым потомком, что нарушает спецификацию. display: contents делает контейнер невидимым для браузера, а children «встраиваются» в родителя. Пример на TypeScript:function Container({ as: Tag = 'div', children }: { as?: string; children: ReactNode }) {
return <Tag style={{ display: 'contents' }}>{children}</Tag>
}Но если у контейнера есть
aria-label или role, они теряются для скринридеров — это частая ошибка в production.Анимации и неожиданное поведение
display: contents удаляет элемент из визуального дерева. Анимации на нем (opacity, transform) перестают работать. Дети отрисовываются мгновенно, без плавности. Чинить так:// Плохо: анимация на контейнере с contents
<div style={{ display: 'contents', opacity: 0 }}> // Не сработает
// Хорошо: обертка отдельно
<div style={{ display: 'contents' }}>
<div style={{ opacity: 0, transition: 'opacity 0.3s' }}>...</div>
</div>
Где не работает и trade-offs
* Табличные элементы (
<tr>, <td>) не поддерживают contents — верстка ломается.* Корневые элементы (
<html>, <body>) — не пытайтесь.* ARIA-атрибуты на контейнере пропадают для доступности: если нужен
aria-label для скринридера, выноси его на детей или используй role="presentation" отдельно.Практический совет
В production используй
display: contents только для контейнеров без собственных стилей и анимаций. Например, в layout-компоненте:function FlexItem({ children }: { children: ReactNode }) {
return <div style={{ display: 'contents' }}>{children}</div>
}Это уменьшает дублирование стилей и сохраняет семантику. Но анимированные элементы всегда оборачивай дополнительно.
Вывод:
display: contents — мощный инструмент для чистого DOM и семантики, но требует явного разделения стилей и анимаций, иначе ломает визуальное поведение и доступность.❤5👀4👍3
Типизация forwarded refs в React 19: баги с
React 19 сделал
1.
Если передать
TypeScript выдаст ошибку, так как не видит контекст Refs. Решение — оборачивать в
Это гарантирует корректную инференцию типов и совместимость с React 19.
2. Инфер типов в HOC с forwarded refs — теряется связь
Когда оборачиваешь компонент с
Без явного
3. Render-props с
Частая ошибка: TypeScript выводит
Вывод: В React 19 явная типизация forwarded refs с generic-типами в каждом слое — не опция, а необходимость для production-кода, иначе баги с инференцией ломают компоненты с HOC и render-props.
ref как prop, инфер типов в HOC и render-props с useImperativeHandleReact 19 сделал
ref обычным prop, что на первый взгляд упрощает API. Однако на практике разработчики часто сталкиваются с неочевидными багами типов, особенно в production-коде с обёртками. Основная ошибка — игнорировать явную типизацию в угоду кажущейся простоте.1.
ref как prop без forwardRef — неочевидный багЕсли передать
ref как обычный prop без forwardRef, TypeScript не поймёт, что это forwarded ref:function MyInput({ ref, ...props }: { ref: React.Ref<HTMLInputElement> }) {
return <input ref={ref} />;
}TypeScript выдаст ошибку, так как не видит контекст Refs. Решение — оборачивать в
forwardRef и явно указывать generic:const MyInput = forwardRef<HTMLInputElement, Props>((props, ref) => ...);
Это гарантирует корректную инференцию типов и совместимость с React 19.
2. Инфер типов в HOC с forwarded refs — теряется связь
Когда оборачиваешь компонент с
ref в HOC, типы часто ломаются. Типичная ошибка: забыть явно пробросить generic для ref:function withLogger<T extends object>(
Component: React.ForwardRefExoticComponent<T & { ref: React.Ref<HTMLDivElement> }>
) {
return forwardRef<HTMLDivElement, T>((props, ref) => {
console.log('Rendered');
return <Component {...props} ref={ref} />;
});
}
Без явного
ForwardRefExoticComponent TypeScript будет гадать, выведя неправильный тип ref. Практический совет: всегда указывай generic для forwarded ref в HOC.3. Render-props с
useImperativeHandle — неправильный тип экземпляраuseImperativeHandle в React 19 работает с forwarded refs, но типизация render-props проваливается, если не объявить интерфейс явно:interface ImperativeActions { focus: () => void; }
const Input = forwardRef<ImperativeActions, {}>((_, ref) => {
useImperativeHandle(ref, () => ({ focus: () => console.log('Focus') }));
return <input />;
});
function Outer({ children }: { children: (ref: React.RefObject<ImperativeActions>) => React.ReactNode }) {
const ref = useRef<ImperativeActions>(null);
return <>{children(ref)}</>;
}Частая ошибка: TypeScript выводит
ImperativeActions как any. Предупреждение: используй React.ElementRef<typeof Input> вместо ручного объявления, чтобы избежать рассинхронизации типов.Вывод: В React 19 явная типизация forwarded refs с generic-типами в каждом слое — не опция, а необходимость для production-кода, иначе баги с инференцией ломают компоненты с HOC и render-props.
❤3👍2👀2👎1
Глубокая типизация GraphQL-ответов без codegen: generics и conditional types в TypeScript
Codegen для GraphQL часто превращается в головную боль: настройка схемы, синхронизация версий, регенерация при каждом изменении. Но если вы используете Relay с фрагментами или работаете в микросервисе с часто меняющейся схемой, можно обойтись без codegen. Типичная ошибка — вручную писать типы для каждого запроса, что для вложенных структур с пагинацией превращается в копипасту.
Почему codegen не всегда нужен
Codegen добавляет слой абстракции, который требует CI/CD-интеграции и синхронизации с серверной схемой. В проектах с частыми изменениями или фрагментарной архитектурой Relay это может стать узким местом. Альтернатива — вывести типы из самого запроса через TypeScript.
Подход: generic + conditional type
Создаем маппер типов GraphQL на TypeScript и utility type, который рекурсивно "проходит" по структуре запроса, обнаруженной через
Акцент на trade-offs
Главный плюс — один дженерик на все запросы без codegen. Но это требует ручного поддержания
Типичные ошибки
* Забыть указать
* Использовать при сложных интерфейсах: conditional type не всегда корректно обрабатывает discriminated unions — это потребует дополнительных проверок.
* Не синхронизировать маппер: если схема меняется, типы рассинхронизируются, и вы получите ложные гарантии.
Практический совет
Применяйте этот подход для микросервисов с простой, стабильной схемой или когда codegen конфликтует с Relay-фрагментарной структурой. В production я так типизировал запросы в проекте, где схема менялась раз в неделю, а codegen падал из-за кэширования — это сэкономило время без потери безопасности типов.
Вывод: Generics и conditional types — это легковесная альтернатива codegen для типизации вложенных GraphQL-ответов, но она требует ручного управления маппером и подходит для схем ограниченного размера.
Codegen для GraphQL часто превращается в головную боль: настройка схемы, синхронизация версий, регенерация при каждом изменении. Но если вы используете Relay с фрагментами или работаете в микросервисе с часто меняющейся схемой, можно обойтись без codegen. Типичная ошибка — вручную писать типы для каждого запроса, что для вложенных структур с пагинацией превращается в копипасту.
Почему codegen не всегда нужен
Codegen добавляет слой абстракции, который требует CI/CD-интеграции и синхронизации с серверной схемой. В проектах с частыми изменениями или фрагментарной архитектурой Relay это может стать узким местом. Альтернатива — вывести типы из самого запроса через TypeScript.
Подход: generic + conditional type
Создаем маппер типов GraphQL на TypeScript и utility type, который рекурсивно "проходит" по структуре запроса, обнаруженной через
as const. Этот тип заменяет строки на реальные типы из схемы, обрабатывая вложенность и массивы.// Маппер типов схемы
type GraphQLTypeMap = {
ID: string;
String: string;
User: { id: string; name: string; posts: Post[] };
Post: { id: string; title: string; comments: Comment[] };
Comment: { id: string; text: string; author: User };
};
// Рекурсивный conditional type
type UnwrapGraphQLResponse<T> = T extends { __typename: infer Name }
? T & GraphQLTypeMap[Name extends keyof GraphQLTypeMap ? Name : never]
: T extends (infer U)[]
? UnwrapGraphQLResponse<U>[]
: T;
// Запрос с as const
const query = {
user: {
id: true,
name: true,
posts: {
title: true,
comments: {
text: true,
author: { name: true }
}
}
}
} as const;
// Тип ответа выводится автоматически
type Response = UnwrapGraphQLResponse<typeof query>;
Акцент на trade-offs
Главный плюс — один дженерик на все запросы без codegen. Но это требует ручного поддержания
GraphQLTypeMap, что для схем с 50+ типами становится трудоемким. Для union/interface типов conditional type может дать сбой — понадобится доплогика. Также метод не работает с динамическими selection set.Типичные ошибки
* Забыть указать
as const — без него TypeScript не зафиксирует структуру запроса.* Использовать при сложных интерфейсах: conditional type не всегда корректно обрабатывает discriminated unions — это потребует дополнительных проверок.
* Не синхронизировать маппер: если схема меняется, типы рассинхронизируются, и вы получите ложные гарантии.
Практический совет
Применяйте этот подход для микросервисов с простой, стабильной схемой или когда codegen конфликтует с Relay-фрагментарной структурой. В production я так типизировал запросы в проекте, где схема менялась раз в неделю, а codegen падал из-за кэширования — это сэкономило время без потери безопасности типов.
Вывод: Generics и conditional types — это легковесная альтернатива codegen для типизации вложенных GraphQL-ответов, но она требует ручного управления маппером и подходит для схем ограниченного размера.
❤4👀2
Object.assign() и structuredClone() в React стейте: баги с прототипами и Date
При immutable-обновлении вложенных объектов в React стейте разработчики часто полагаются на поверхностное копирование через spread или Object.assign(), забывая, что эти методы не сохраняют прототипы и сериализуют Date в строку. Это приводит к тихим багам, которые проявляются только в production при работе с классами или сложными типами.
Баг с прототипами: Object.assign() обнуляет методы класса
Когда объект содержит экземпляр класса, Object.assign({}, obj) копирует только собственные свойства, игнорируя прототип. Например, методы полностью теряются:
Баг с Date: structuredClone() превращает дату в строку
В React стейте это особенно опасно для UI-компонентов, которые полагаются на методы Date (например, календари или таймеры). Типичная ошибка: разработчики думают, что structuredClone() копирует Date нативно, но по спецификации оно заменяет Date на строку UTC. Совет: используйте
Trade-off: поверхностное vs глубокое копирование
- Поверхностное копирование (spread, Object.assign) быстрое и простое, но не подходит для вложенных структур с прототипами или ссылочными типами (Date, Map). Риск: мутация общего стора через ссылки.
- Глубокое копирование (structuredClone, Immer) безопаснее, но медленнее и несовместимо с некоторыми типами. Практический совет: для production используйте Immer, который корректно работает с прототипами и Date, а structuredClone применяйте только для изолированных данных (cookies, clipboard).
Вывод: immutable-обновление в React требует осознанного выбора метода копирования в зависимости от типов данных в стейте, а не слепого использования spread "на всякий случай".
При immutable-обновлении вложенных объектов в React стейте разработчики часто полагаются на поверхностное копирование через spread или Object.assign(), забывая, что эти методы не сохраняют прототипы и сериализуют Date в строку. Это приводит к тихим багам, которые проявляются только в production при работе с классами или сложными типами.
Баг с прототипами: Object.assign() обнуляет методы класса
Когда объект содержит экземпляр класса, Object.assign({}, obj) копирует только собственные свойства, игнорируя прототип. Например, методы полностью теряются:
class User {
constructor(name) { this.name = name; }
greet() { return Hi, ${this.name}; }
}
const original = new User('Alice');
const copy = Object.assign({}, original);
console.log(copy.greet()); // TypeError: copy.greet is not a functionstructuredClone() решает эту проблему для сериализуемых типов, но классы с методами не поддерживаются — код упадет с DOMException. Решение: если в стейте лежат инстансы классов, используйте кастомную функцию глубокого копирования с проверкой прототипа через Object.getPrototypeOf.Баг с Date: structuredClone() превращает дату в строку
structuredClone() сериализует Date в строку, как JSON.stringify. После клонирования методы Date (getTime, toISOString) перестают работать:const state = { date: new Date('2024-12-25') };
const clone = structuredClone(state);
console.log(clone.date.getTime()); // TypeError: clone.date.getTime is not a functionВ React стейте это особенно опасно для UI-компонентов, которые полагаются на методы Date (например, календари или таймеры). Типичная ошибка: разработчики думают, что structuredClone() копирует Date нативно, но по спецификации оно заменяет Date на строку UTC. Совет: используйте
structuredClone только для данных, которые проходят полный список сериализуемых типов (Map, Set, ArrayBuffer), а для Date заводите отдельный хелпер или библиотеку вроде Immer.Trade-off: поверхностное vs глубокое копирование
- Поверхностное копирование (spread, Object.assign) быстрое и простое, но не подходит для вложенных структур с прототипами или ссылочными типами (Date, Map). Риск: мутация общего стора через ссылки.
- Глубокое копирование (structuredClone, Immer) безопаснее, но медленнее и несовместимо с некоторыми типами. Практический совет: для production используйте Immer, который корректно работает с прототипами и Date, а structuredClone применяйте только для изолированных данных (cookies, clipboard).
Вывод: immutable-обновление в React требует осознанного выбора метода копирования в зависимости от типов данных в стейте, а не слепого использования spread "на всякий случай".
👍5❤2🐳2👀1
Мемоизация в React 19: когда useMemo и memo уже не панацея, а новые use() и React Forget меняют правила игры
Сколько раз вы писали
Почему старые инструменты не идеальны
Мемоизация не бесплатна.
Новый хук use() — отказ от явного кеширования промисов
Хук
React Forget — компилятор как замена ручной оптимизации
React Forget анализирует граф вызовов и сам встраивает кеширование. Если функция вызывается с теми же аргументами, компилятор возвращает прошлое значение, причём без утечек памяти. Разработчику не надо думать, где ставить
Типичная ошибка: мемоизация как архитектурный костыль
Если ваш код на 80% состоит из
Вывод: Будущее за компиляторами, которые избавляют от рутины — useMemo и memo остаются инструментами для узких горячих путей, а не ежедневной практикой.
Сколько раз вы писали
useMemo(() => expensiveComputation(a, b), [a, b]) и сомневались, нужно ли это вообще? Или оборачивали компонент в memo, надеясь, что поверхностное сравнение пропсов не окажется дороже самого рендера? В React 19 старые подходы перестают быть единственно верными: появились инструменты, которые делают ручную мемоизацию узким, а не основным решением.Почему старые инструменты не идеальны
Мемоизация не бесплатна.
useMemo хранит ссылки и пересчитывает зависимости при каждом рендере. memo добавляет сравнение пропсов, и если дерево сложное, а данные меняются часто, выигрыша нет. В production я видел проекты, где 80% вызовов useMemo и useCallback не давали прироста — это была оптимизация ради оптимизации без профилирования. Типичная ошибка: мемоизировать все подряд, а не только горячие пути.Новый хук use() — отказ от явного кеширования промисов
Хук
use() компилируется в синхронный рендер и убирает необходимость вручную кешировать асинхронные данные. Пример: вместо того чтобы писать useEffect с загрузкой и оборачивать результат в useMemo, вы просто используете use(fetchUsers()). Компилятор сам решает, когда перерендерить компонент, без лишних проверок и ссылочных сравнений. Это особенно полезно для SSR и Suspense — мемоизация тут не нужна, рендер планируется автоматически.React Forget — компилятор как замена ручной оптимизации
React Forget анализирует граф вызовов и сам встраивает кеширование. Если функция вызывается с теми же аргументами, компилятор возвращает прошлое значение, причём без утечек памяти. Разработчику не надо думать, где ставить
useMemo — это происходит на уровне анализа зависимостей. Практический совет: используйте это на побочных проектах уже сейчас, но не спешите переписывать всё в проде. Для 95% сценариев React Forget достаточно, но на очень горячих путях — например, рендер таблицы на 10 тысяч строк — ручной useMemo всё ещё может быть оправдан.Типичная ошибка: мемоизация как архитектурный костыль
Если ваш код на 80% состоит из
useMemo и useCallback, это повод пересмотреть архитектуру, а не добавлять новые обёртки. use() берёт на себя загрузку данных, Suspense — ленивую подгрузку, а React Forget — всё остальное. Главный trade-off: экономия времени на ручной мемоизации против возможных дополнительных рендеров на этапе доработки компилятора. В production это редко становится проблемой, если дерево компонентов спроектировано с учётом React Forget.Вывод: Будущее за компиляторами, которые избавляют от рутины — useMemo и memo остаются инструментами для узких горячих путей, а не ежедневной практикой.
👍5👎2
Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.
Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤4👎4👀3
Реактивный DataV: оптимизация 10k+ точек с event-driven аннотациями и hit-тестингом
Когда на графике 10 000+ точек, часто выбирают: производительность или интерактивность. Прямой рендер в SVG тормозит, Canvas летает, но теряет hover и click. Это ложный компромисс.
Гибридный подход
Решение — Canvas для рендера точек (никаких DOM-узлов, только пиксели). Hover и click выносятся в event-driven аннотации через raycasting. Считай только при движении мыши, а не на каждый фрейм.
Ленивые аннотации и кеширование
Рендери только те аннотации, которые попадают в viewport, и кешируй результат. Иначе каждый чих дергает рендер на 10k точках.
Пример hit-тестинга
Без фреймворков, на TypeScript:
Сначала проверка по bounding box, потом по расстоянию. Для 10k точек O(n) срабатывает за пару миллисекунд. QuadTree даёт O(log n), но не всегда нужен.
Предупреждение о типичной ошибке
Главный подвох: не забудь сбросить аннотацию при уходе мыши с точки. Иначе график зависнет с подписями на все 10k, и пользователь решит, что интерфейс сломался.
Вывод:
Event-driven аннотации с hit-тестингом — это trade-off между точностью интерактивности и производительностью рендера, где Canvas даёт скорость, а raycasting сохраняет UX.
Когда на графике 10 000+ точек, часто выбирают: производительность или интерактивность. Прямой рендер в SVG тормозит, Canvas летает, но теряет hover и click. Это ложный компромисс.
Гибридный подход
Решение — Canvas для рендера точек (никаких DOM-узлов, только пиксели). Hover и click выносятся в event-driven аннотации через raycasting. Считай только при движении мыши, а не на каждый фрейм.
Ленивые аннотации и кеширование
Рендери только те аннотации, которые попадают в viewport, и кешируй результат. Иначе каждый чих дергает рендер на 10k точках.
Пример hit-тестинга
Без фреймворков, на TypeScript:
class ScatterPlot {
handleMouseMove(e) {
for (const p of this.points) {
if (Math.abs(mouseX - p.x) <= p.r * 2 &&
Math.abs(mouseY - p.y) <= p.r * 2) {
if (Math.hypot(mouseX - p.x, mouseY - p.y) <= p.r) {
this.showAnnotation(i);
return;
}
}
}
}
showAnnotation(index) {
requestAnimationFrame(() => this.renderAnnotationLayer());
}
}Сначала проверка по bounding box, потом по расстоянию. Для 10k точек O(n) срабатывает за пару миллисекунд. QuadTree даёт O(log n), но не всегда нужен.
Предупреждение о типичной ошибке
Главный подвох: не забудь сбросить аннотацию при уходе мыши с точки. Иначе график зависнет с подписями на все 10k, и пользователь решит, что интерфейс сломался.
Вывод:
Event-driven аннотации с hit-тестингом — это trade-off между точностью интерактивности и производительностью рендера, где Canvas даёт скорость, а raycasting сохраняет UX.
❤4
Типизация WebSocket событий: как discriminated unions + brand checks делают стейт-машину предсказуемой и спасают от багов с payload
Каждый, кто писал real-time фичи на React или Vue, сталкивался: диспатчишь событие WebSocket, а payload — не того типа. Структура похожа, семантика разная, и TypeScript молчит. Ошибка уходит в production.
Discriminated unions — база, но не панацея
Стандартный подход: поле
Brand checks — второй рубеж
Добавьте фиктивное поле
Пример типизации:
Типичная ошибка
Думать, что brand checks — это всё. WebSocket может прислать что угодно. Типы не панацея. В production обязательно докидывайте валидацию схемы на каждое входящее сообщение через Zod или io-ts.
Практический совет
Используйте brand checks как инструмент для разработчика, чтобы не протащить payload не туда при сборке стейта. А рантайм-валидацию — как первую линию защиты от кривых данных с сервера. Вместе они дают надёжность и предсказуемость в real-time архитектуре.
Вывод: Discriminated unions сужают типы по значению, brand checks защищают от семантических багов, а рантайм-валидация — от любых неожиданностей с сервера: три уровня защиты для production-grade стейт-машины.
Каждый, кто писал real-time фичи на React или Vue, сталкивался: диспатчишь событие WebSocket, а payload — не того типа. Структура похожа, семантика разная, и TypeScript молчит. Ошибка уходит в production.
Discriminated unions — база, но не панацея
Стандартный подход: поле
type сужает остальные поля. Но если два события содержат sessionId с разным смыслом, TS не заметит подмены. На уровне типов всё ок, в рантайме — треш.Brand checks — второй рубеж
Добавьте фиктивное поле
__brand: 'open' в payload. В рантайме его нет, типы не гоняют лишние данные. Компилятор не даст смешать payload разных событий. Это имитация номинативной типизации в структурной системе TypeScript. Стейт-машина перестаёт принимать невесть что: переходы становятся предсказуемыми.Пример типизации:
type WSMessage =
| { type: 'OPEN'; payload: { sessionId: string } & { __brand: 'open' } }
| { type: 'MSG'; payload: { userId: number; text: string } & { __brand: 'msg' } }
| { type: 'ERROR'; payload: { error: string } & { __brand: 'err' } };
Типичная ошибка
Думать, что brand checks — это всё. WebSocket может прислать что угодно. Типы не панацея. В production обязательно докидывайте валидацию схемы на каждое входящее сообщение через Zod или io-ts.
Практический совет
Используйте brand checks как инструмент для разработчика, чтобы не протащить payload не туда при сборке стейта. А рантайм-валидацию — как первую линию защиты от кривых данных с сервера. Вместе они дают надёжность и предсказуемость в real-time архитектуре.
Вывод: Discriminated unions сужают типы по значению, brand checks защищают от семантических багов, а рантайм-валидация — от любых неожиданностей с сервера: три уровня защиты для production-grade стейт-машины.
👎9❤5🐳2
Почему useSyncExternalStore проигрывает Zustand и Jotai на композитных сторах и как селекторы убивают производительность
Пытался вчера прикрутить
Бенчмарк на 1000 подписчиков: примитивы vs вложенные объекты
На простом сторе (один селектор, один примитив) разница в микросекундах — Jotai ~0.15ms, Zustand ~0.18ms,
Zustand без
Jotai с производными атомами — та же петрушка.
Грабли с селекторами: деструктуризация убивает производительность
Ошибка, которую я вижу на каждом проекте — деструктуризация в теле селектора:
Каждый вызов селектора создаёт новый объект.
Правильный подход — писать селекторы на отдельные поля:
Тогда
Дополнительные меры: Zustand — используйте
Когда useSyncExternalStore — зло для бизнес-логики
Сейчас на проекте простая архитектура: простые состояния — Zustand, меньше кода. Композитные данные — Jotai с
Вывод: Микрооптимизация селекторов и отказ от композитных возвращаемых объектов — это не premature optimization, а разница между 1.2ms и 0.3ms ререндера при масштабировании на 1000+ подписчиков.
Пытался вчера прикрутить
useSyncExternalStore к композитному стору в React 19. Думал, что если это API из коробки, то и проблем с производительностью не будет. На деле — пришлось переписывать половину.Бенчмарк на 1000 подписчиков: примитивы vs вложенные объекты
На простом сторе (один селектор, один примитив) разница в микросекундах — Jotai ~0.15ms, Zustand ~0.18ms,
useSyncExternalStore ~0.22ms. Можно не париться. Как только в сторе появляется вложенный объект — начинается треш.Zustand без
shallow перерисовывает всех подписчиков при любом изменении. Даже если твой селектор брал только state.user, а кто-то обновил state.posts. Причина: Zustand при каждом вызове селектора сравнивает результат через Object.is с предыдущим. Если селектор возвращает новый объект — привет, ререндер.Jotai с производными атомами — та же петрушка.
atom(get => { return { user: get(userAtom), posts: get(postsAtom) } }) — создаёт новый объект на каждый вызов. Jotai также сравнивает через Object.is, так что ререндер гарантирован.Грабли с селекторами: деструктуризация убивает производительность
Ошибка, которую я вижу на каждом проекте — деструктуризация в теле селектора:
const { user, posts } = useStore(state => ({
user: state.user,
posts: state.posts
}))Каждый вызов селектора создаёт новый объект.
Object.is никогда не вернёт true.Правильный подход — писать селекторы на отдельные поля:
const user = useStore(state => state.user)
const posts = useStore(state => state.posts)
Тогда
Object.is вернёт true, если ссылка не изменилась.Дополнительные меры: Zustand — используйте
shallow для сравнения. Jotai — atomWithComparator. Экономия до 40% ререндеров.Когда useSyncExternalStore — зло для бизнес-логики
useSyncExternalStore вообще не про бизнес-логику. Его пилили под внешние источники: WebSocket, IndexedDB. Внутри React он не батчит подписки, так что при 1000 подписчиков и частых изменениях — 1.2ms на ререндер. Jotai с мемоизацией — 0.3ms. Zustand с shallow — 0.6ms. Разница в 4 раза — это уже не погрешность.Сейчас на проекте простая архитектура: простые состояния — Zustand, меньше кода. Композитные данные — Jotai с
atomFamily и splitAtom. useSyncExternalStore — только если прикручиваешь что-то внешнее.Вывод: Микрооптимизация селекторов и отказ от композитных возвращаемых объектов — это не premature optimization, а разница между 1.2ms и 0.3ms ререндера при масштабировании на 1000+ подписчиков.
👎5🐳3👀2😁1😱1
requestAnimationFrame и React-батчинг: почему дёргается drag-and-drop
Сделал перетаскивание элементов в production? На быстрых движениях начинаются дёргания и выпадающие кадры. Проблема знакомая:
В React 18+ батчинг работает в рамках одного события, но rAF срабатывает до мерджа обновлений. Результат: анимация читает старую позицию, React применяет новые значения с задержкой, элемент дёргается.
Решение с flushSync
Для чтения DOM-значений (позиция, размер) используй
Предупреждение
Не злоупотребляй
Альтернативы
- Замени
- Если хочешь не трогать React-состояние внутри кадра, храни позиции в
Вывод:
Сделал перетаскивание элементов в production? На быстрых движениях начинаются дёргания и выпадающие кадры. Проблема знакомая:
requestAnimationFrame вызывается до того, как React применяет батч из setState. Особенно если rAF висит на mousemove вне реактовских обработчиков.В React 18+ батчинг работает в рамках одного события, но rAF срабатывает до мерджа обновлений. Результат: анимация читает старую позицию, React применяет новые значения с задержкой, элемент дёргается.
Решение с flushSync
Для чтения DOM-значений (позиция, размер) используй
useLayoutEffect — выполняется до показа кадра. Внутри drag-логики добавь синхронный вызов:const handleMouseMove = (e: MouseEvent) => {
ReactDOM.flushSync(() => {
setPos((prev) => ({
x: prev.x + e.movementX,
y: prev.y + e.movementY
}));
});
requestAnimationFrame(updateStyles);
};flushSync заставляет React применить батч синхронно перед следующим кадром. Дёргания уходят.Предупреждение
Не злоупотребляй
flushSync — он ломает Concurrent Mode и режет производительность, если вызывать на каждом mousemove. Типичная ошибка: считать его универсальным решением без оценки trade-offs.Альтернативы
- Замени
mousemove/touchmove на pointer-events — они синхроннее в браузере.- Если хочешь не трогать React-состояние внутри кадра, храни позиции в
useRef и обновляй DOM руками через rAF. Но это редкий кейс, и он разрушает реактивность.Вывод:
requestAnimationFrame и React-батчинг конфликтуют по умолчанию — контролируй синхронизацию через flushSync или выноси анимации за пределы реактовского цикла, иначе даже гладкий код споткнётся на быстром перемещении.👍9👀2