Заметки фром реакт документейшен | React: грабли, о которые я спотыкалась. Часть 1. Concurrent Mode.. Concurrent Mode. JavaScript.. Concurrent Mode. JavaScript. react.. Concurrent Mode. JavaScript. react. React.memo.. Concurrent Mode. JavaScript. react. React.memo. ReactJS.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript. useCallback.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript. useCallback. useEffect.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript. useCallback. useEffect. useMemo.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript. useCallback. useEffect. useMemo. useRef.. Concurrent Mode. JavaScript. react. React.memo. ReactJS. StrictMode. TypeScript. useCallback. useEffect. useMemo. useRef. useState.

React я использую совсем недавно и вообще я бэкенд разработчик, но жизнь завела меня во фронтенд. Получилось так, что времени на обучение и курсы у меня не было, пришлось обучаться прямо в процессе.

Мой принцип был “работает и ладно”, но принципы нормального фронтенда явно отличались. Шишек я набила знатных с точки зрения чистоты и логики кода.

Это статья для таких же новичков, как я. Представляю вам сборник граблей, о которые я спотыкалась сама, и решений к этим граблям, которые родились после нескольких часов дебага и внимательного изучения документации (спустя пол года во фронтенде пора бы, да?)

Оглавление

  1. useState

  2. useEffect

  3. useCallback и useMemo

  4. useRef

  5. Контекст

  6. React.memo

  7. Портал

  8. Строгий режим (StrictMode)

  9. Concurrent Mode

1. useState

Проблема

Когда я только начинала, я думала, что setState работает как обычное присваивание. Написала setCount(count + 1) — и ожидала, что count сразу станет новым. Каково же было мое удивление, когда в следующей строке count все еще был старым.

function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1);
    console.log(count); // Все еще 0!
  };
}

Почему так происходит

setState не меняет переменную сразу. Он говорит React: “эй, тут изменилось состояние, запланируй перерендер”. React ставит это обновление в очередь и выполняет его позже, когда сочтет нужным.

Как правильно

Если новое значение зависит от предыдущего, используйте функцию-апдейтер:

setCount(prev => prev + 1);

Вот поэтому стоит сначала документацию читать, а потом писать проект.

В дополнение к этой же проблеме:

1. Батчинг

Если вы решите сделать так:

const handleClick = () => {
  setCount(count + 1);
  setCount(count + 1);
  // Будет 1, а не 2! (если count = 0)
};

Это не сработает.
Почему? Потому что оба вызова используют одно и то же значение count (0). React просто дважды вызывает setCount(1). Мы об этом уже говорили, поэтому решение просто:

setCount(prev => prev + 1);
setCount(prev => prev + 1); // Теперь будет 2

2. Иммутабельность

Это была моя самая частая ошибка. Я мутировала объект и думала, что React заметит.

const [user, setUser] = useState({ name: 'John', age: 30 });

// Так НЕ РАБОТАЕТ
user.age = 31;
setUser(user); // React не заметит изменения!

// А ТАК РАБОТАЕТ
setUser({ ...user, age: 31 }); // Создаем новый объект

Почему? React сравнивает объекты по ссылкам. Если вы мутируете объект, ссылка остается той же. React думает: “объект не изменился, перерендер не нужен”.

3. useState с функциями

Если начальное значение требует вычислений, можно передать функцию:

const [state, setState] = useState(() => {
  const expensive = heavyCalculation();
  return expensive;
});

Функция выполнится только один раз при инициализации.

Коротко про useState

  • setState — асинхронный, не жди мгновенного обновления

  • Для обновления от предыдущего значения используй prev => prev + 1

  • Не мутируй объекты и массивы в стейте — создавай новые копии

2. useEffect

Проблема

Однажды мой компонент просто завис. Браузер выдал ошибку “Maximum update depth exceeded”. Я не понимала, что происходит, пока не нашла причину:

function MyComponent() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1);
  }, [count]);
}

Я тогда полдня сидела и не понимала, что за фигня. Думала, может браузер сломался или реакт глючит. А оказалось, я сама себе яму вырыла, во-первых, не пониманием, что такое useEffect вообще и как он работает, а, во-вторых… ладно, первым все сказано.

Почему так происходит

React: count изменился → перерендер → эффект сработал → count снова изменился → перерендер → и так по кругу. Добрый день, бесконечный цикл.

Как правильно

// Вариант 1: не добавлять count в зависимости
useEffect(() => {
  setCount(prev => prev + 1); // именно через =>, иначе какой-нибудь ESLint начнет ругаться на нас за отсутствие зависимости 
}, []);

// Вариант 2: добавить условие
useEffect(() => {
  if (count < 10) {
    setCount(count + 1);
  }
}, [count]);

Дополнительно:

  1. Порядок выполнения

Еще один сюрприз — я думала, что useEffect выполняется в том порядке, в котором написаны компоненты. Оказалось, не всегда.

function Parent() {
  useEffect(() => console.log('Parent'), []);
  return <Child />;
}

function Child() {
  useEffect(() => console.log('Child'), []);
  return null;
}

// Вывод: Parent, Child
// При размонтировании: Child, Parent

Почему так? React сначала выполняет все эффекты родителя, потом дочерние. При размонтировании — наоборот.

  1. useLayoutEffect

useLayoutEffect я использую редко, но когда нужен — без него никуда. Он выполняется синхронно после изменений в DOM, но до отрисовки. Если тебе нужно измерить высоту элемента до того, как пользователь увидит — это оно.

useLayoutEffect(() => {
  const height = elementRef.current.offsetHeight;
  setHeight(height);
}, []);

Важно: не делайте в нём тяжелых вычислений, иначе браузер зависнет.

  1. Очистка эффектов

Если в эффекте есть подписки, таймеры или event listeners — их нужно чистить:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);
  
  return () => clearInterval(timer); // очистка
}, []);

Иначе они продолжат работать в фоне и будут пытаться обновлять состояние размонтированного компонента.

Коротко про useEffect

  • Не вызывай setState в эффекте без условия — получишь бесконечный цикл

  • При монтировании: эффекты родителя выполняются раньше дочерних. При размонтировании — наоборот.

  • useLayoutEffect для измерений до отрисовки

  • Всегда чисти подписки и таймеры

3. useCallback и useMemo

Проблема

Когда я увидела useCallback и useMemo в классах, которые написал другой фронт, я начала оборачивать в них всё что можно. Думала — ну так наверное надо, потом, уже коротко почитав что это за штуки, — так быстрее будет. Оказалось — нет.

const handleClick = useCallback(() => {
  console.log(value);
}, []); // Пустой массив — value никогда не обновится и будет такой, как и при первом рендере!

Почему так происходит

Если dependencies пустые, функция запоминает value на момент первого рендера. При обновлении value функция все равно будет использовать старое значение. Я на это наткнулась, когда делала форму редактирования — данные не обновлялись, а я не понимала почему.

Как правильно

const handleClick = useCallback(() => {
  console.log(value);
}, [value]); // Зависимость указана

Когда это реально нужно

useCallback нужен для того, чтобы функция не пересоздавалась при каждом рендере. Это полезно, когда:

  1. Вы передаете функцию в React.memo компонент

  2. Функция используется в useEffect как зависимость

useMemo для дорогих вычислений:

const expensiveValue = useMemo(() => {
  return heavyCalculation(data);
}, [data]);

Если вычисление простое — не используйте useMemo, это лишняя работа для React.

Важно

React может удалить мемоизированное значение, если ему понадобится память. Поэтому нельзя рассчитывать на useMemo как на гарантированное хранилище.

Коротко про мемоизацию

  • Не мемоизируй всё подряд — это вредит производительности

  • useCallback — для функций, useMemo — для значений

  • Всегда указывай зависимости правильно

4. useRef

Проблема

useRef я использовала только для ссылок на DOM-элементы и проблем не возникло. Но когда понадобилось хранить значение между рендерами (например, ID таймера или флаг монтирования), я пыталась использовать useState и получала лишние перерендеры.

Почему так происходит

useRef хранит значение в .current и не вызывает перерендер при изменении. React не следит за изменениями ref, в отличие от useState. Поэтому если тебе нужно хранить данные, которые не должны обновлять UI — это useRef.

useRef

useState

Не вызывает перерендер

Вызывает перерендер

Мутабельный

Иммутабельный

Для служебных данных

Для данных в UI

Как правильно

const inputRef = useRef(null);
<input ref={inputRef} />
inputRef.current.focus(); // Для DOM-ссылок

Для хранения любых данных без перерендера:

const timerRef = useRef(null);
const isMounted = useRef(true);
const previousValue = useRef();

Если нужно передать ref в дочерний компонент — используй forwardRef:

const Child = forwardRef((props, ref) => {
  return <input ref={ref} />;
});

Если нужно дать родителю доступ к методам дочернего компонента — useImperativeHandle:

const Child = forwardRef((props, ref) => {
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => inputRef.current.value = ''
  }));
});

Коротко

  • useRef — для DOM-ссылок и любых данных без перерендера

  • Изменение ref.current не обновляет UI

  • forwardRef для передачи ref в дочерние компоненты

  • Подходит для таймеров, флагов, свайпов и инстансов библиотек

5. Контекст

Проблема

Сначала я подумала, что контекст это просто хранилище, в которое можно засунуть всё состояние приложения. Делать я так конечно не стала, но мысль вероятно обвалить всё приложение все же была.

const AppContext = React.createContext();

function AppProvider({ children }) {
  const [state, setState] = useState({
    user: null,
    theme: 'light',
    notifications: [],
    // ... еще куча полей
  });
  
  return (
    <AppContext.Provider value={{ state, setState }}>
      {children}
    </AppContext.Provider>
  );
}

Почему так происходит

При изменении любого поля перерендеривались все компоненты, которые использовали этот контекст. Даже те, которые не зависели от измененного поля.
(В бэкенде я таких подлянок даже придумать не смогла. Если ты где-то что-то обновилось при изменении одного поля, то это скорее Event надо использовать)

Как правильно

Вариант 1: разбить контекст на несколько маленьких

const UserContext = React.createContext();
const ThemeContext = React.createContext();
const NotificationsContext = React.createContext();

Вариант 2: мемоизировать значение контекста

const value = useMemo(() => ({ state, setState }), [state]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;

Вариант 3: использовать useReducer + контекст

const [state, dispatch] = useReducer(reducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);

Контекст хорош для:

  • Текущей темы

  • Языка локализации

  • Авторизованного пользователя (не часто меняется)

Контекст НЕ для:

  • Сложного состояния с частыми обновлениями

  • Состояния, которое меняется каждую секунду

Альтернатива: для сложного состояния используйте Redux. Он оптимизирован.

Коротко про контекст

  • Не суй всё в один контекст

  • Мемоизируй значение контекста

  • Для сложного состояния — Redux

6. React.memo

Проблема

Я обернула компонент в React.memo и удивилась — он все равно перерендеривался.

function Parent() {
  const handleClick = () => { // Новая функция при каждом рендере
    console.log('clicked');
  };
  
  return <MemoizedChild onClick={handleClick} />;
}

const MemoizedChild = React.memo(function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
});

Почему так происходит

React.memo сравнивает пропсы поверхностно: если пропс — функция, он сравнивает ссылки. А при каждом рендере родителя создается новая функция handleClick. Даже если логика функции не меняется, ссылка на нее каждый раз новая. Поэтому React.memo думает: “пропс изменился, надо перерендерить”.

Как правильно

function Parent() {
  const handleClick = useCallback(() => { // Одна и та же ссылка
    console.log('clicked');
  }, []);
  
  return <MemoizedChild onClick={handleClick} />;
}

Но даже с useCallback мемоизация имеет смысл только если дочерний компонент действительно тяжелый. Иногда оверхед от мемоизации больше, чем выгода.

Короче, используйте с умом. React.memo полезен когда:

  1. Компонент рендерится часто

  2. В компоненте тяжелые вычисления

  3. Компонент получает одни и те же пропсы, но перерендеривается из-за родителя

Если нужно сравнить пропсы по-своему:

const MemoizedChild = React.memo(Child, (prevProps, nextProps) => {
  return prevProps.id === nextProps.id; // только по id
});

Коротко

  • React.memo + useCallback работают в связке

  • Не оборачивай всё подряд — только если есть реальная проблема

  • React.memo — про сравнение, useCallback — про стабильность ссылок

7. Портал

Проблема

Я делала тултип, который должен был показываться над элементом. Просто использовала position: absolute и позиционировала его относительно родителя. Всё работало, пока родитель не оказался внутри контейнера с overflow: hidden.

Тултип обрезался. Я пыталась поднять z-index, ставить position: fixed — не помогало, потому что родительский контейнер резал всё, что выходило за его границы.

function Tooltip({ children, text }) {
  const [isVisible, setIsVisible] = useState(false);
  
  return (
    <div 
      className="tooltip-container"  // overflow: hidden у родителя
      onMouseEnter={() => setIsVisible(true)}
      onMouseLeave={() => setIsVisible(false)}
    >
      {children}
      {isVisible && (
        <div className="tooltip">  // обрезается!
          {text}
        </div>
      )}
    </div>
  );
}

Почему так происходит

Когда тултип лежит внутри дива с overflow: hidden (и этот стиль тут принципиально нужен), он обрезается по границам этого дива. position: absolute не спасает, потому что он всё равно остаётся внутри родительского контейнера. position: fixed тоже не всегда работает, если у родителя есть transform или filter.

Как правильно

Тут и нужен портал. Тултип рендерится в body, вне родительского контейнера, и не обрезается.

function Tooltip({ children, text }) {
  const [isVisible, setIsVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const triggerRef = useRef(null);

  const showTooltip = () => {
    const rect = triggerRef.current.getBoundingClientRect();
    setCoords({
      top: rect.bottom + 8,
      left: rect.left + rect.width / 2
    });
    setIsVisible(true);
  };

  return (
    <>
      <div 
        ref={triggerRef}
        onMouseEnter={showTooltip}
        onMouseLeave={() => setIsVisible(false)}
      >
        {children}
      </div>
      
      {isVisible && ReactDOM.createPortal(
        <div 
          className="tooltip"
          style={{ 
            position: 'fixed',
            top: coords.top,
            left: coords.left,
            transform: 'translateX(-50%)'
          }}
        >
          {text}
        </div>,
        document.body
      )}
    </>
  );
}
  • Тултип рендерится в body — не обрезается родительским overflow: hidden

  • Позиционируется через getBoundingClientRect() — всегда точно над элементом

  • Не зависит от родительских стилей — никаких transform и filter

Но использовать лучше только по необходимости опять же. К примеру:

  • Тултипы

  • Дропдауны в сложных контейнерах

  • Любые всплывающие элементы, которые могут обрезаться

  • Модалки, которые не влазят в родительский контейнер

Коротко

Портал помогает, когда элемент должен вырваться из родительского контейнера с overflow: hidden, transform или filter. Тултип — самый частый пример.

8. Строгий режим (StrictMode)

Если вы перешли во фронтенд как я, то как только появится время — пройдитесь по коду, который писал ваш старший разраб (если он у вас есть), и загуглите все штуки, назначение которых вы не знаете.
Иначе вы и знать не узнаете, что такое <StrictMode> где-то в main.tsx и зачем оно вам надо.

Что это

(для понятности вот вам ссылка на оф документацию)

<StrictMode> — это обертка, которая в режиме разработки помогает находить ошибки на раннем этапе. В продакшене он ничего не делает.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

В React 18+ StrictMode в разработке делает вот что:

  1. Дважды рендерит компоненты — чтобы проверить, нет ли мутаций данных. Если компонент чистый (pure), два рендера не изменят результат. Если он напрямую изменяет пропсы или глобальные переменные — это станет видно.

  2. Монтирует, размонтирует и снова монтирует компоненты (это важно!). React делает так:

    Монтирование → Эффекты → Размонтирование → Очистка → Монтирование → Эффекты

Это проверяет, правильно ли вы чистите эффекты. Если вы забыли return с очисткой (отписка, удаление таймера), вы это сразу увидите.

  1. Проверяет устаревшие API — предупреждает о методах, которые могут удалить в будущих версиях (например, componentWillMountcomponentWillReceiveProps и т.д.).

  2. Перезапускает ref-колбэки дважды — чтобы проверить, что вы правильно удаляете ссылки.

  3. Проверяет нестандартное использование хуков — например, нарушение правил хуков.

Пример с эффектом

useEffect(() => {
  console.log('Подключение к чату', roomId);
  const connection = createConnection(roomId);
  connection.connect();
  
  // Если забыть очистку — в StrictMode увидите проблему
  // return () => connection.disconnect();
}, [roomId]);

Что увидите в консоли с StrictMode:

Подключение к чату room1
Подключение к чату room1  // Дважды!

Почему? React в StrictMode делает так:

Монтирование → effect сработал → размонтирование → снова монтирование → effect снова сработал

Если вы добавили очистку, то увидите:

Подключение к чату room1
Отключение от чата room1
Подключение к чату room1

Это нормально! В продакшене будет только один лог. А в разработке StrictMode помогает убедиться, что очистка работает корректно.

Важно

  • StrictMode работает только в разработке. В продакшене он отключается.

  • Его нельзя отключить для части дерева — если включил наверху, то все внутри будет проверяться.

  • Он помогает отловить ошибки, которые сложно воспроизвести в продакшене.

Проблема

Не могла понять, почему в деве эффект выполняется дважды

useEffect(() => {
  console.log('effect'); // В development выведется дважды
}, []);

Почему так происходит

Как я и объясняла, <StrictMode> специально дважды рендерит компоненты в development, чтобы найти проблемы с побочными эффектами.

Коротко про StrictMode

  • Только для development

  • Помогает найти проблемы

  • Не влияет на production

9. Concurrent Mode

После того как разобралась с базовыми хуками и объяснила себе непонятные штуки, пора переходить к приколюхам, которые делают приложения лучше. Одна из таких — Concurrent Mode.

Что это

Concurrent Mode — это режим, в котором React может прерывать рендеринг, чтобы не блокировать интерфейс. Тяжелые обновления выполняются в фоне, а пользователь продолжает тыкать в кнопки.

(вот статья на Хабре от умного человека, который разжевал эту тему. Здесь только краткая выжимка)

Как пишет автор статьи: в основе Concurrent Mode лежит Fiber-архитектура, которую добавили еще в React 16. Она работает как планировщик задач — каждые ~16 мс React проверяет, не пора ли прервать текущий рендер и показать что-то более важное.

Проблема

До React 18 рендеринг был синхронным. React начинал обновление и не мог остановиться, пока всё не отрендерит. Если компонент тяжелый (например, фильтрация списка на 1000 элементов), интерфейс просто висел, пока всё не посчитается.

Решение

Concurrent Mode позволяет прерывать рендер и выполнять обновления с разным приоритетом. Появляются новые инструменты:

  • Suspense — управляет загрузкой данных. Можно начать рендерить компонент, даже если данные еще не пришли.

  • useTransition — откладывает неважные обновления и дает флаг isPending, чтобы показать, что что-то грузится.

  • useDeferredValue — возвращает отложенную версию значения. Пока грузится новое, показывает старое, чтобы интерфейс не дергался.

  • SuspenseList — управляет порядком загрузки нескольких компонентов.

const [isPending, startTransition] = useTransition();

setSearchTerm(value); // важно — делаем сразу

startTransition(() => { // неважно — можно подождать
  setResults(filterData(value));
});

{isPending && <Spinner />}

Коротко

  • Рендер можно прерывать

  • startTransition — для неважных обновлений

  • useDeferredValue — для отложенных значений

  • Включается через createRoot вместо ReactDOM.render

В итоге

Перейти из одной области разработки в другую без проблем не выйдет. Не пренебрегайте изучением документации и статьей сообщества. И следи за обновлениями! Потому что React — это инструмент, который постоянно развивается. То, что работало год назад, сегодня может быть устаревшим. Или то, что в прошлой версии казалось сказкой, в этой может быть уже осуществимо.

Ну и хватит на сегодня. О нюансах React можно говорить вечно и столько же пытаться запомнить всё. Строго не судите, я постаралась разобрать именно мои ошибки. Ну и если вам есть что сказать, то пишите!)

Полезные ссылки

Автор: Ingini

Источник