Заметки фром реакт документейшен | React: грабли, о которые я спотыкалась. Часть 1
- среда, 5 августа 2026 г. в 00:00:08
React я использую совсем недавно и вообще я бэкенд разработчик, но жизнь завела меня во фронтенд. Получилось так, что времени на обучение и курсы у меня не было, пришлось обучаться прямо в процессе.
Мой принцип был "работает и ладно", но принципы нормального фронтенда явно отличались. Шишек я набила знатных с точки зрения чистоты и логики кода.
Это статья для таких же новичков, как я. Представляю вам сборник граблей, о которые я спотыкалась сама, и решений к этим граблям, которые родились после нескольких часов дебага и внимательного изучения документации (спустя пол года во фронтенде пора бы, да?)
Когда я только начинала, я думала, что 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; });
Функция выполнится только один раз при инициализации.
setState — асинхронный, не жди мгновенного обновления
Для обновления от предыдущего значения используй prev => prev + 1
Не мутируй объекты и массивы в стейте — создавай новые копии
Однажды мой компонент просто завис. Браузер выдал ошибку "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]);
Дополнительно:
Порядок выполнения
Еще один сюрприз — я думала, что useEffect выполняется в том порядке, в котором написаны компоненты. Оказалось, не всегда.
function Parent() { useEffect(() => console.log('Parent'), []); return <Child />; } function Child() { useEffect(() => console.log('Child'), []); return null; } // Вывод: Parent, Child // При размонтировании: Child, Parent
Почему так? React сначала выполняет все эффекты родителя, потом дочерние. При размонтировании — наоборот.
useLayoutEffect
useLayoutEffect я использую редко, но когда нужен — без него никуда. Он выполняется синхронно после изменений в DOM, но до отрисовки. Если тебе нужно измерить высоту элемента до того, как пользователь увидит — это оно.
useLayoutEffect(() => { const height = elementRef.current.offsetHeight; setHeight(height); }, []);
Важно: не делайте в нём тяжелых вычислений, иначе браузер зависнет.
Очистка эффектов
Если в эффекте есть подписки, таймеры или event listeners — их нужно чистить:
useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1); }, 1000); return () => clearInterval(timer); // очистка }, []);
Иначе они продолжат работать в фоне и будут пытаться обновлять состояние размонтированного компонента.
Не вызывай setState в эффекте без условия — получишь бесконечный цикл
При монтировании: эффекты родителя выполняются раньше дочерних. При размонтировании — наоборот.
useLayoutEffect для измерений до отрисовки
Всегда чисти подписки и таймеры
Когда я увидела useCallback и useMemo в классах, которые написал другой фронт, я начала оборачивать в них всё что можно. Думала — ну так наверное надо, потом, уже коротко почитав что это за штуки, — так быстрее будет. Оказалось — нет.
const handleClick = useCallback(() => { console.log(value); }, []); // Пустой массив — value никогда не обновится и будет такой, как и при первом рендере!
Если dependencies пустые, функция запоминает value на момент первого рендера. При обновлении value функция все равно будет использовать старое значение. Я на это наткнулась, когда делала форму редактирования — данные не обновлялись, а я не понимала почему.
const handleClick = useCallback(() => { console.log(value); }, [value]); // Зависимость указана
useCallback нужен для того, чтобы функция не пересоздавалась при каждом рендере. Это полезно, когда:
Вы передаете функцию в React.memo компонент
Функция используется в useEffect как зависимость
useMemo для дорогих вычислений:
const expensiveValue = useMemo(() => { return heavyCalculation(data); }, [data]);
Если вычисление простое — не используйте useMemo, это лишняя работа для React.
React может удалить мемоизированное значение, если ему понадобится память. Поэтому нельзя рассчитывать на useMemo как на гарантированное хранилище.
Не мемоизируй всё подряд — это вредит производительности
useCallback — для функций, useMemo — для значений
Всегда указывай зависимости правильно
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 в дочерние компоненты
Подходит для таймеров, флагов, свайпов и инстансов библиотек
Сначала я подумала, что контекст это просто хранилище, в которое можно засунуть всё состояние приложения. Делать я так конечно не стала, но мысль вероятно обвалить всё приложение все же была.
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
Я обернула компонент в 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 полезен когда:
Компонент рендерится часто
В компоненте тяжелые вычисления
Компонент получает одни и те же пропсы, но перерендеривается из-за родителя
Если нужно сравнить пропсы по-своему:
const MemoizedChild = React.memo(Child, (prevProps, nextProps) => { return prevProps.id === nextProps.id; // только по id });
React.memo + useCallback работают в связке
Не оборачивай всё подряд — только если есть реальная проблема
React.memo — про сравнение, useCallback — про стабильность ссылок
Я делала тултип, который должен был показываться над элементом. Просто использовала 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. Тултип — самый частый пример.
Если вы перешли во фронтенд как я, то как только появится время — пройдитесь по коду, который писал ваш старший разраб (если он у вас есть), и загуглите все штуки, назначение которых вы не знаете.
Иначе вы и знать не узнаете, что такое <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 в разработке делает вот что:
Дважды рендерит компоненты — чтобы проверить, нет ли мутаций данных. Если компонент чистый (pure), два рендера не изменят результат. Если он напрямую изменяет пропсы или глобальные переменные — это станет видно.
Монтирует, размонтирует и снова монтирует компоненты (это важно!). React делает так:
Монтирование → Эффекты → Размонтирование → Очистка → Монтирование → Эффекты
Это проверяет, правильно ли вы чистите эффекты. Если вы забыли return с очисткой (отписка, удаление таймера), вы это сразу увидите.
Проверяет устаревшие API — предупреждает о методах, которые могут удалить в будущих версиях (например, componentWillMount, componentWillReceiveProps и т.д.).
Перезапускает ref-колбэки дважды — чтобы проверить, что вы правильно удаляете ссылки.
Проверяет нестандартное использование хуков — например, нарушение правил хуков.
Пример с эффектом
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, чтобы найти проблемы с побочными эффектами.
Только для development
Помогает найти проблемы
Не влияет на production
После того как разобралась с базовыми хуками и объяснила себе непонятные штуки, пора переходить к приколюхам, которые делают приложения лучше. Одна из таких — 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 можно говорить вечно и столько же пытаться запомнить всё. Строго не судите, я постаралась разобрать именно мои ошибки. Ну и если вам есть что сказать, то пишите!)
ESLint plugin для хуков — помогает найти ошибки