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

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

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

Оглавление

  1. useState

  2. useEffect

  3. useCallback и useMemo

  4. useRef

  5. Контекст

  6. React.memo

  7. Портал

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

  9. Concurrent Mode

1. useState

Начался мой путь с осознавания что такое хуки и почему вдруг условно глобальные переменные сами по себе не изменяются как надо. Во-первых, здесь они называются состояниями, а во-вторых, у них есть отдельная функция, изменяющая их.

Проблема

Проблема уровня "подлянка при первых шагах в React": setState не работает как обычное присваивание.

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

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

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

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

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

setCount(prev => prev + 1);

Здесь prev это как значение внутри функции, поэтому можно как угодно назвать.
Если вы знакомы с c#, то для пояснения использую вам аналогию с делегатами в linq-запросах:

.Where(x => x.Id == request.Id)
.Select(prev => prev.Count);

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

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

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. Иммутабельность

Опят с c# путал меня и заставлял изменять переменные класса (тут даже класса-то нет) не через set-функцию, а по старинке через присваивание

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

user.age = 31;
setUser(user); // React не заметит изменения

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

Почему? Да хотя бы потому что user задается как const. А менять константу нельзя.
Как говорит нам оф документация: "В React состояние считается доступным только для чтения, поэтому вам следует заменять его, а не изменять существующие объекты."

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

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

const [state, setState] = useState(heavyCalculation);

Но тут тоже важно как вы эту функцию передадите. Если будет heavyCalculation() — используется только при первом рендеринге, вы все равно вызываете эту функцию при каждом рендеринге.
А без () — будет инициализатором функции. И тогда она выполнится только один раз.

Коротко про 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]);

Еще мне подсказали, что есть пример в более хитрой форме:

type Props = {arr?: string[]}; 
function Component({arr = []}: Props) 
{ 
	// ... 
	useEffect(() => 
	{
		// обновляем состояние 
	}, [arr]); 
	// ... 
}

Если в такой компонент не передать проп arr, то будет бесконечный ререндер, потому что в arr попадает каждый раз новый пустой массив.
Спасибо, @Alexandroppolus

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

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

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

function Parent({ children }) {
  console.log("Parent is rendered");
  useEffect(() => {
    console.log("Parent committed effect");
  }, []);

  return <div>{children}</div>;
}

function Child() {
  console.log("Child is rendered");
  useEffect(() => {
    console.log("Child committed effect");
  }, []);

  return <p>Child</p>;
}

export default function App() {
  return (
    <Parent>
      <Child />
    </Parent>
  );
}

// Вывод: 

// initial render
Parent is rendered
Child is rendered

// useEffects
Child committed effect
Parent committed effect

Почему так? Дочерние компоненты отображаются последними, а их эффекты коммитятся первыми. Более конкретное объяснение можно прочитать в указанной статье.

2. useLayoutEffect

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

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

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

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

Если в эффекте есть подписки, таймеры или 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

Как использовать

Для DOM-ссылок:

const inputRef = useRef(null);
<input ref={inputRef} />
inputRef.current.focus();

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

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

И еще две функции в дополнение:

1. forwardRef

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

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

2. 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? Если читать документацию, то 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.

Тултип некрасиво обрезался. Поскольку в тот момент поменять overflow мне было нельзя, я пыталась поднять у тултипа 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.

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

Во-первых, убедитесь, что вы не можете поставить overflow: visible, а уже потом прибегайте ко второму варианту:

Портал.

Вот так тултип будет рендерится в 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, 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. Монтирует, размонтирует и снова монтирует компоненты (это важно!). Ну то есть:

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

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

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

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

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

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

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

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

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

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

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

Важно

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

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

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

Проблема

Ну тут все понятно: не могла понять, почему в деве эффект выполняется дважды и вечно пыталась поставить какой-то флаг на использование метода один раз.

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

Объяснила выше.

Коротко про StrictMode

  • Только для development

  • Монтирует - размонтирует - монтирует

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 можно говорить вечно и столько же пытаться запомнить всё. Строго не судите, я постаралась разобрать именно мои ошибки. Ну и если вам есть что сказать, то пишите!)

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

Комментарии (3)


  1. Fragster
    04.08.2026 10:48

    Статья хорошая. Пока остаюсь на Vue.


  1. Alexandroppolus
    04.08.2026 10:48

    React сначала выполняет все эффекты родителя, потом дочерние

    Эффекты - наоборот, сначала дочерние. А вот рендер сначала выполняется родительский.

    Бесконечный ререндер из-за useEffect видел в чуть более хитрой форме:

    type Props = {arr?: string[]};
    
    function Component({arr = []}: Props) {
      // ...
    
      useEffect(() => {
        // обновляем состояние
      }, [arr]);
    
      // ...
    }

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


    1. Ingini Автор
      04.08.2026 10:48

      Действительно очень хитрая форма. Спасибо!