React 18: ошибки совместимости хуков при обновлении с версии 17

mr. Cooper 2 дня назад Веб-разработка
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 возникают из-за двух вещей:

  1. Разработчик переключился на createRoot, но код писался в расчёте на старую модель рендеринга - например, делал несколько setState подряд, рассчитывая на строгую последовательность.

  2. 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 в разработке - он специально помогает находить проблемы до того, как они попадут в продакшн.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи