Frontender's notes [ru]
Ведущий канал о современном фронтенде: статьи, новости, практики, вайбкодинг и автоматизация фронта ИИ-агентами. Личный блог автора - @just_genych По вопросам рекламы или разработки - @g_abashkin
إظهار المزيد📈 نظرة تحليلية على قناة تيليجرام Frontender's notes [ru]
تُعد قناة Frontender's notes [ru] (@frontendnoteschannel_ru) في القطاع اللغوي الروسية لاعباً نشطاً. يضم المجتمع حالياً 32 276 مشتركاً، محتلاً المرتبة 4 215 في فئة التكنولوجيات والتطبيقات والمرتبة 20 090 في منطقة روسيا.
📊 مؤشرات الجمهور والحراك
منذ تأسيسه في невідомо، حقق المشروع نمواً سريعاً وجمع 32 276 مشتركاً.
بحسب آخر البيانات بتاريخ 30 يونيو, 2026، تحافظ القناة على نشاط مستقر. خلال آخر 30 يوماً تغيّر عدد الأعضاء بمقدار -332، وفي آخر 24 ساعة بمقدار -12، مع بقاء الوصول العام مرتفعاً.
- حالة التحقق: غير موثّقة
- معدل التفاعل (ER): يبلغ متوسط تفاعل الجمهور 7.74%. وخلال أول 24 ساعة من النشر يحصد المحتوى عادةً 4.63% من ردود الفعل نسبةً إلى إجمالي المشتركين.
- وصول المنشورات: يحصل كل منشور على متوسط 2 498 مشاهدة. وخلال اليوم الأول يجمع عادةً 1 495 مشاهدة.
- التفاعلات والاستجابة: يتفاعل الجمهور بانتظام؛ متوسط التفاعلات لكل منشور يبلغ 13.
- الاهتمامات الموضوعية: يركز المحتوى على مواضيع رئيسية مثل браузер, api, css, интерфейс, загрузка.
📝 الوصف وسياسة المحتوى
يصف المؤلف القناة بأنها مساحة للتعبير عن الآراء الذاتية:
“Ведущий канал о современном фронтенде: статьи, новости, практики, вайбкодинг и автоматизация фронта ИИ-агентами.
Личный блог автора - @just_genych
По вопросам рекламы или разработки - @g_abashkin”
بفضل وتيرة التحديث المرتفعة (أحدث البيانات بتاريخ 01 يوليو, 2026) تحافظ القناة على حداثتها ومستوى وصول مرتفع. وتُظهر التحليلات تفاعلاً نشطاً من الجمهور، ما يجعلها نقطة تأثير مهمة ضمن فئة التكنولوجيات والتطبيقات.
{{#each}} и {{#if}}. Прямая передача таких строк в JSX ломает TypeScript, но есть способ это контролировать на уровне типов.
Branded типы как контракт данных
Branded тип - это строка с уникальным символом, которая не может быть создана без специальной фабрики. Обычная строка не скомпилируется в такой тип, что предотвращает случайную передачу сырого шаблона.
type HandlebarsTemplate = string & { __brand: 'Handlebars' };
function hbs(strings: TemplateStringsArray, ...values: any[]): HandlebarsTemplate {
return strings.reduce((acc, str, i) => acc + str + (values[i] || ''), '') as HandlebarsTemplate;
}
function jsx(tag: string, props: { template?: HandlebarsTemplate } | null, ...children: any[]) {
if (tag === 'Template' && props?.template) {
return compileAndRender(props.template);
}
return React.createElement(tag, props, ...children);
}
Типичная ошибка и её устранение
Если передать сырую строку напрямую, TypeScript выдаст ошибку:
// ❌ Ошибка: Type 'string' is not assignable to type 'HandlebarsTemplate'
<Template template="<div>{{user}}</div>" />
Корректный вариант с фабрикой:
const template = hbs<div>{{#if user}}Hello {{user.name}}{{/if}}</div>;
function MyComponent() {
return <Template template={template} />;
}
Production-oriented практика
Чтобы кастомная JSX-фабрика работала, настрой Babel или TypeScript: добавь @jsx jsx в начале файла или укажи jsxFactory: 'jsx' в tsconfig. Для гибридных проектов это гарантирует, что ни один сырой шаблон не проскочит в рендер без компиляции. Если шаблон использует переменные контекста, добавь generics для типизации payload:
function hbs<T>(strings: TemplateStringsArray, ...values: any[]): HandlebarsTemplate<T>;
Trade-offs
Дополнительный слой абстракции усложняет читаемость и увеличивает bundle, если шаблонов много. Используй такой подход только в пограничных сценариях миграции, а не как постоянную практику.
Вывод: Branded типы и кастомные JSX-фабрики позволяют безопасно встраивать императивные шаблоны в декларативный JSX, предотвращая runtime-ошибки на этапе компиляции.custom hooks принимают конфиги, где TypeScript выводит mode: string вместо 'edit' | 'view', а permissions — string[] вместо конкретных литералов. В production это ведет к потере автодополнения и багам на стороне потребителей.
Constraints: фиксируем рамки
Используй extends уже на уровне дженериков хука, чтобы сузить допустимые типы. Без него TS выводит широкие типы, с ним — точные литералы, если исходные данные переданы с as const.
function useFeature<M extends string, P extends string[]>(config: {
mode: M;
permissions: P;
}) {
// TS: M, P как литералы
}
Infer: выводим сложные зависимости
Когда конфиг содержит вложенные коллбэки или динамические ключи, infer в условных типах автоматически извлекает нужные типы из переданного объекта. Это спасает от ручного аннотирования.
type ConfigResult<T> = T extends { mode: infer M; permissions: infer P }
? { mode: M; permissions: P }
: never;
Типичная ошибка: забытый as const
Без as const TS выведет string, а не литералы. А если конфиг приходит из API — этот подход бесполезен. Зато для статически заданных конфигов в хуках (фичи, A/B-тесты, permission guards) это чистый профит: типы не протекают, код самодокументируется.
Вывод: Constraints с extends и вывод через infer дают строгую типизацию factory-функций и конфигурационных объектов без потери читаемости и с проверками на уровне компиляции.ErrorBoundary отлавливал только синхронные ошибки, а запросы с then/catch просто пролетали мимо. В 19 версии это починили через use и новые хуки, но типизация все равно остается местом, где можно наступить на грабли в production.
Интерфейс и цепочка ошибок
Интерфейс ErrorBoundaryState с Error | null, getDerivedStateFromError ловит ошибку, componentDidCatch отправляет в лог. Но что действительно спасает в реальных кейсах — это error.cause из ES2022. Когда fetch падает с 401, ты пробрасываешь не строку, а объект:
* error.cause?.status для проверки статуса
* error.cause?.message для текста
interface AppError extends Error {
cause?: {
status: number;
message: string;
};
}
Production: сужение и безопасность
Передавать полный stack клиенту — плохая идея. Там пути файлов и внутренние адреса. Мы обрезаем первые 200 символов, если ошибка некастомная, и никогда не светим причину наружу: error.cause только во внутренний логгер.
Схема логирования через instanceof:
* если error instanceof AppError — пишем структурированно с типом, причиной и стеком
* если просто Error — обрезаем стек, кидаем предупреждение
* если вообще не Error — логируем как строку и показываем generic fallback
Типичная ошибка
instanceof не работает, если ошибка прилетела из другого iframe или realm. Там error.cause может быть сериализованным объектом, и придется проверять по полям вручную. Встречал такое при интеграции с микросервисными виджетами. Еще один момент — не засовывай всю логику в componentDidCatch: вынеси ее в отдельную функцию handleFatalError с возвратом never и переиспользуй, иначе при рефакторинге придется переписывать каждый Boundary.
Вывод: Типизированный ErrorBoundary с error.cause и строгим логированием через instanceof — это надежный паттерн для обработки асинхронных ошибок, который дает консистентность и безопасность в production.
Источники: React docs про Error Boundaries, MDN про Error.cause, React 19 release notes про use() hook и async errors.error.cause и правильная типизация через instanceof делают обработку асинхронных ошибок предсказуемой и безопасной.
Почему any в state — ошибка
Состояние границы должно быть строго типизировано: Error | null. В getDerivedStateFromError проверяй cause через instanceof Error, не через truthy check. Это исключает мусор из продакшена, где cause может быть строкой, числом или undefined.
Production-кейс с fetch
При HTTP 500 выбрасывай:
throw new Error('HTTP error', { cause: res.status })
В границе проверяй:
if (error.cause instanceof Error) {
// Логируем полный стек
} else if (typeof error.cause === 'number') {
// Игнорируем, если статус < 500
}
Это позволяет не слать в Sentry временные сетевые сбои.
Типизация cause и instanceof
Используй instanceof для разграничения классов ошибок — HTTPError, NetworkError, ValidationError. Так ты контролируешь, что идёт в Sentry, а что — только в консоль. Без этого логгер захлебнётся шумом.
Вывод:
Типизированный ErrorBoundary с error.cause и instanceof превращает асинхронные ошибки из невидимых багов в управляемые инциденты.const rootRoute = createRootRoute()({
loader: () => ({ user: 'John' })
});
const layoutRoute = createRoute({
getParentRoute: () => rootRoute,
loader: ({ context }) => ({
theme: context.user === 'John' ? 'dark' : 'light'
})
});
const pageRoute = createRoute({
getParentRoute: () => layoutRoute,
loader: ({ context }) => {
// context: { user: string, theme: string }
return context.theme;
}
});
Ни одного явного интерфейса или каста. Система выводит тип контекста из цепочки родительских роутов.
Production-кейс: аутентификация с правами
Практический пример: вложенный layout с авторизацией. Родитель кладет user, дочерний — permissions:
const authLayout = createRoute({
getParentRoute: () => rootRoute,
loader: () => ({ user: fetchUser() })
});
const adminLayout = createRoute({
getParentRoute: () => authLayout,
loader: ({ context }) => ({
permissions: fetchPermissions(context.user.id)
})
});
Страница получает { user, permissions } без ручного объявления. Если переименовать user в currentUser — TypeScript подсветит все места, где используется старый ключ.
Типичная ошибка и trade-off
Ошибка: ручное объявление interface IContext = { user: string } и последующий as any при несоответствии. Это ломает всю type-safety и ведет к runtime-ошибкам при изменении данных.
Trade-off: полагаться на вывод типов чуть сложнее читать в IDE, чем явные интерфейсы, но выигрыш в надежности при рефакторинге — код сам документирует контракты, и компилятор ловит несоответствия.
Вывод: Используйте автоматический вывод типов контекстов через цепочку getParentRoute, чтобы избавиться от ручных интерфейсов и кастингов, повысив надежность и упростив рефакторинг в production-коде.count = signal(1)
price = signal(100)
total = computed(() => count() * price())
discount = computed(() => total() > 500 ? 0.1 : 0)
final = computed(() => total() * (1 - discount()))
При изменении count пересчитываются total и final. Discount пересчитывается только если total пересечет порог. В RxJS через combineLatest discount будет пересчитываться на каждое изменение count и price, даже если total не изменился. Меньше кода — больше бесполезной работы.
Когда Observable все еще нужен
Observable (RxJS) хорош для асинхронных цепочек: debounce, switchMap, WebSocket. Это декларативные потоки, где важны трансформации во времени. Но при сотне зависимостей начинается цирк: каждый чих триггерит пересчёт, дебажить dependency hell — отдельный квест. Типичная ошибка — использовать Observable для синхронного графа зависимостей, где сигналы дают ровно то же самое без лишних пересчётов.
Практический совет
Если у вас в проекте уже RxJS — не переписывайте всё. Для нового фича с interdependent состояниями (формы, редакторы, дашборды) закладывайтесь на сигналы. В продакшене с сотнями зависимостей сигналы дают на 30-50% меньше бесполезных перерисовок и упрощают отладку: граф зависимостей прозрачен, а не взрывается на каждой итерации.
Вывод: Signal — для синхронных графов с предсказуемыми изменениями, Observable — для event-driven асинхронных потоков, и выбор между ними — это trade-off между производительностью вычислений и гибкостью трансформаций.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 или выноси анимации за пределы реактовского цикла, иначе даже гладкий код споткнётся на быстром перемещении.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+ подписчиков.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 стейт-машины.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.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 остаются инструментами для узких горячих путей, а не ежедневной практикой.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 function
structuredClone() решает эту проблему для сериализуемых типов, но классы с методами не поддерживаются — код упадет с 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 "на всякий случай".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-ответов, но она требует ручного управления маппером и подходит для схем ограниченного размера.ref как prop, инфер типов в HOC и render-props с useImperativeHandle
React 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.
متاح الآن! بحث تيليغرام 2025 — أهم رؤى العام 
