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

Alexandroppolus
04.08.2026 10:48React сначала выполняет все эффекты родителя, потом дочерние
Эффекты - наоборот, сначала дочерние. А вот рендер сначала выполняется родительский.
Бесконечный ререндер из-за useEffect видел в чуть более хитрой форме:
type Props = {arr?: string[]}; function Component({arr = []}: Props) { // ... useEffect(() => { // обновляем состояние }, [arr]); // ... }Если в такой компонент не передать проп arr, то будет бесконечный ререндер, потому что в arr попадает каждый раз новый пустой массив.
Fragster
Статья хорошая. Пока остаюсь на Vue.