React я использую совсем недавно и вообще я бэкенд разработчик, но жизнь завела меня во фронтенд. Получилось так, что времени на обучение и курсы у меня не было, пришлось обучаться прямо в процессе.
Мой принцип был "работает и ладно", но принципы нормального фронтенда явно отличались. Шишек я набила знатных с точки зрения чистоты и логики кода.
Это статья для таких же новичков, как я. Представляю вам сборник граблей, о которые я спотыкалась сама, и решений к этим граблям, которые родились после нескольких часов дебага и внимательного изучения документации (спустя пол года во фронтенде пора бы, да?)
Оглавление
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]);
Дополнительно:
Порядок выполнения
Еще один сюрприз — я думала, что 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); // очистка }, []);
Иначе они продолжат работать в фоне и будут пытаться обновлять состояние размонтированного компонента.
Коротко про 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 нужен для того, чтобы функция не пересоздавалась при каждом рендере. Это полезно, когда:
Вы передаете функцию в
React.memoкомпонентФункция используется в
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не обновляет UIforwardRefдля передачи 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 полезен когда:
Компонент рендерится часто
В компоненте тяжелые вычисления
Компонент получает одни и те же пропсы, но перерендеривается из-за родителя
Если нужно сравнить пропсы по-своему:
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 в разработке делает вот что:
Дважды рендерит компоненты — чтобы проверить, нет ли мутаций данных. Если компонент чистый (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, чтобы найти проблемы с побочными эффектами.
Коротко про 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 можно говорить вечно и столько же пытаться запомнить всё. Строго не судите, я постаралась разобрать именно мои ошибки. Ну и если вам есть что сказать, то пишите!)
Полезные ссылки
ESLint plugin для хуков — помогает найти ошибки