React 18: ошибки совместимости хуков при обновлении с версии 17
Обновляете проект с React 17 до 18 и столкнулись с тем, что хуки ведут себя не так, как раньше? Компоненты рендерятся дважды, стейт обновляется в неожиданный момент, а в консоли появляются предупреждения, которых раньше не было - это знакомо многим разработчикам. Статья поможет разобраться, почему так происходит и как исправить каждую из типичных проблем. Материал будет полезен как тем, кто только начинает миграцию, так и тем, кто уже уткнулся в конкретную ошибку.
Что изменилось в React 18 относительно хуков
React 18 - не просто инкрементальное обновление. Вместе с новым корневым API (createRoot) появился Concurrent Mode, который меняет модель рендеринга. Теперь React может прерывать, откладывать и повторять рендер компонентов. Именно из-за этого часть хуков работает иначе.
Три ключевых изменения, которые влияют на хуки:
Автоматический батчинг - setState теперь группируется даже внутри промисов и нативных событий, не только в обработчиках React-событий.
Строгий режим стал жёстче - в StrictMode React намеренно вызывает компонент дважды (монтирование → размонтирование → монтирование) для выявления побочных эффектов.
Новые хуки - useId, useTransition, useDeferredValue, useSyncExternalStore, useInsertionEffect.
Почему возникают ошибки совместимости
Большинство проблем при переходе React 17 → 18 возникают из-за двух вещей:
Разработчик переключился на createRoot, но код писался в расчёте на старую модель рендеринга - например, делал несколько setState подряд, рассчитывая на строгую последовательность.
StrictMode в React 18 unmount и remount компонент при разработке - если useEffect содержал подписку без корректной функции очистки, это раньше не бросалось в глаза.
Важно: если вы используете старый ReactDOM.render вместо createRoot, React 18 работает в legacy-режиме без Concurrent Mode. В этом случае поведение хуков почти идентично React 17. Ошибки начинаются именно после перехода на createRoot.
Частые ошибки и проблемы при миграции
1. Компонент рендерится дважды в StrictMode
Симптом: в консоли дважды выводится console.log из тела компонента или useEffect. Кажется, что данные запрашиваются дважды.
Причина: React 18 в режиме разработки специально монтирует компонент, размонтирует его и монтирует снова. Это поведение намеренное - так React проверяет, что у вас нет незакрытых подписок или утечек.
Это не баг в вашем коде - это предупреждение. Но если useEffect делает запрос к API без флага отмены, запрос действительно пойдёт дважды.
// ❌ Проблема: нет отмены запроса
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []);
// ✅ Решение: AbortController
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, []);2. Автоматический батчинг ломает логику, завязанную на порядок стейтов
Симптом: несколько setState подряд внутри промиса или setTimeout раньше вызывали несколько рендеров. В React 18 они объединяются в один.
// React 17: 2 рендера
// React 18: 1 рендер - оба setState батчатся
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);Если ваша логика опирается на то, что между двумя setState произойдёт промежуточный рендер - она сломается.
Решение: пересмотрите логику. Если действительно нужен промежуточный рендер, используйте flushSync:
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1));
// Здесь рендер уже произошёл
flushSync(() => setFlag(f => !f));flushSync отключает батчинг принудительно. Используйте аккуратно - это дорого по производительности.
3. useEffect с зависимостями стал строже - предупреждение exhaustive-deps
В React 18 ESLint-плагин eslint-plugin-react-hooks стал точнее отслеживать зависимости. Код, который молча работал в 17, теперь выдаёт предупреждения или ведёт себя иначе.
// ❌ Функция пересоздаётся каждый рендер, useEffect вызывается бесконечно
const fetchData = () => { /* ... */ };
useEffect(() => { fetchData(); }, [fetchData]);
// ✅ Оберните функцию в useCallback
const fetchData = useCallback(() => { /* ... */ }, []);
useEffect(() => { fetchData(); }, [fetchData]);4. useLayoutEffect и SSR - предупреждение в консоли
Симптом: Warning: useLayoutEffect does nothing on the server because its effect cannot be encoded into the server renderer's output format.
useLayoutEffect запускается синхронно после DOM-мутации, но на сервере DOM нет. React 18 c SSR (например, через Next.js 13+) стал строже на этот счёт.
Решение: замените useLayoutEffect на useEffect если серверный рендер не нужен, или используйте паттерн с проверкой окружения:
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;5. Сторонние библиотеки с устаревшими хуками
После обновления React некоторые пакеты (старые версии react-redux, react-spring, mobx-react) начинают выбрасывать ошибки вроде:
Warning: An update to Component inside a test was not wrapped in act(...)или
TypeError: Cannot read properties of null (reading 'useState')Причина: библиотека использует internal React API, которые изменились.
Решение: обновить зависимости. React 18 требует:
react-redux ≥ 8.0
react-router-dom ≥ 6.4 для полной поддержки
@testing-library/react ≥ 13.0
Пошаговое руководство по миграции React 17 → 18
Шаг 1. Обновите пакеты
npm install react@18 react-dom@18
# или
yarn add react@18 react-dom@18Также обновите типы, если используете TypeScript:
npm install --save-dev @types/react@18 @types/react-dom@18Шаг 2. Замените ReactDOM.render на createRoot
// ❌ React 17 (устарело)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// ✅ React 18
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);Шаг 3. Обновите зависимости под React 18
Запустите аудит:
npm ls reactНайдите пакеты, которые тянут React 17 как peerDependency, и обновите их.
Шаг 4. Проверьте useEffect на утечки
Пройдитесь по всем useEffect с пустым массивом зависимостей. Убедитесь, что каждый, кто создаёт подписку, таймер или запрос, возвращает функцию очистки.
Шаг 5. Запустите тесты и исправьте act()
В @testing-library/react v13 большинство вещей оборачиваются в act автоматически. Если видите предупреждение - обновите библиотеку, а не добавляйте act вручную.
Примеры кода: до и после
Корректная подписка на WebSocket
// ✅ React 18 - корректный useEffect с очисткой
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
socket.onmessage = (e) => setMessages(prev => [...prev, e.data]);
return () => socket.close(); // очистка обязательна
}, [roomId]);
return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}useTransition для тяжёлых операций
import { useState, useTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setQuery(e.target.value); // срочное обновление - сразу
startTransition(() => {
setResults(heavyFilter(e.target.value)); // отложенное
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
);
}Часто задаваемые вопросы (FAQ)
Q: Нужно ли переписывать все хуки при обновлении на React 18? A: Нет. Большинство хуков (useState, useRef, useMemo, useCallback) работают без изменений. Пересматривать нужно прежде всего useEffect с побочными эффектами без очистки.
Q: Почему компонент рендерится дважды только в режиме разработки? A: Это поведение StrictMode в React 18. В продакшн-сборке двойного монтирования нет. Так React помогает выявить баги с незакрытыми ресурсами.
Q: Что делать, если flushSync вызывает ошибку? A: flushSync нельзя вызывать внутри useEffect или другого flushSync. Используйте только в обработчиках событий, когда действительно нужен синхронный рендер.
Q: Как понять, что пакет совместим с React 18? A: Проверьте peerDependencies в package.json пакета. Если там "react": "^17.0.0" без поддержки 18 - нужно обновить пакет или найти форк.
Q: Изменился ли useContext в React 18? A: API не изменился, но в Concurrent Mode контекст может вызывать больше рендеров. Если это критично по производительности - рассмотрите useMemo для значений контекста.
Q: useEffect вызывается после каждого рендера, хотя зависимости не меняются. Почему? A: Скорее всего, объект или массив в зависимостях пересоздаётся при каждом рендере. Оберните его в useMemo или useRef.
Q: Можно ли не переходить на createRoot и остаться на ReactDOM.render? A: Да, ReactDOM.render работает в React 18 в legacy-режиме с предупреждением. Но вы не получите Concurrent Features и поддержка устареет в будущих версиях.
Q: Нужно ли обновлять TypeScript типы? A: Обязательно. Типы @types/react@18 изменились - например, children больше не входит в FC по умолчанию. Нужно явно добавлять React.PropsWithChildren.
Полезные советы и лучшие практики
Мигрируйте постепенно - сначала обновите react и react-dom, запустите тесты, затем переходите на createRoot.
Не игнорируйте предупреждения - React 18 заранее предупреждает о deprecated API. Предупреждение сегодня - ошибка в React 19.
Используйте React.StrictMode в разработке - да, это вызывает двойной рендер, но это ваша защита от скрытых багов.
Добавьте eslint-plugin-react-hooks - правило exhaustive-deps выловит большинство проблем с зависимостями до рантайма.
Не используйте flushSync по умолчанию - батчинг в React 18 улучшает производительность. Отключайте его только при реальной необходимости.
Тестируйте с @testing-library/react ≥ 13 - эта версия нативно поддерживает concurrent рендеринг и убирает ложные предупреждения act.
Итог
Переход с React 17 на 18 - это не переписывание приложения с нуля. Большинство хуков работают так же. Главные точки внимания: корректные функции очистки в useEffect, осознанное использование нового батчинга и обновление зависимостей до версий, совместимых с React 18. Используйте StrictMode в разработке - он специально помогает находить проблемы до того, как они попадут в продакшн.
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.