Хук useRef часто воспринимают исключительно как инструмент для получения ссылок на DOM-элементы. На практике это универсальный контейнер для любых мутабельных данных, которые должны пережить повторный рендер компонента, не вызывая при этом перерисовку интерфейса. Понимание границ его применения позволяет избежать лишних циклов рендера, утечек памяти и скрытых багов при работе с асинхронным кодом.
В чем разница между useRef и useState
Главное концептуальное отличие кроется в реакции React на изменение данных.
Хук useState предназначен для работы с состоянием, напрямую определяющим визуальное представление (JSX). Когда вызывается функция обновления состояния (setState), React планирует новый рендер компонента и его дочернего дерева.
Хук useRef возвращает обычный JavaScript-объект с единственным свойством { current: initialValue }. Ссылка на сам этот объект остается стабильной между всеми рендерами. Изменение свойства ref.current — это обычная мутация объекта в памяти: React о ней не знает и повторный рендер не запускает.
| Критерий | useRef |
useState |
|---|---|---|
| Сохранение данных между рендерами | Да | Да |
| Триггер повторного рендера при изменении | Нет | Да |
| Способ обновления | Мутация: ref.current = value |
Вызов сеттера: setValue(value) |
| Доступ к актуальному значению | Синхронно в любой момент | В следующем цикле рендера |
| Влияние на отрисовку JSX | Не должно влиять напрямую | Напрямую формирует UI |
Правило выбора простое: если значение участвует в условных конструкциях JSX, отображается на экране или влияет на стили элементов, используйте useState. Если значение нужно для внутренних технических нужд (таймеры, предыдущие пропсы, флаги отслеживания, ссылки на DOM), выбирайте useRef.
Управление DOM: фокус при открытии формы
React работает с абстракцией над DOM — виртуальным деревом. Большинство задач решается декларативно через пропсы и состояние. Однако браузерные API для фокуса, скролла или выделения текста требуют императивных вызовов. В таких ситуациях useRef выступает в роли «аварийного люка» (Escape Hatch).
Почему прямой доступ к DOM называют «аварийным люком»
Императивные мутации DOM в обход React могут нарушить синхронизацию между реальным деревом узлов и состоянием библиотеки. Поэтому обращение к нативным методам вроде element.focus() или element.scrollIntoView() должно быть точечным и не должно менять структуру узлов (нельзя вручную удалять или вставлять дочерние теги через appendChild или innerHTML).
Автофокус при появлении формы
Когда в JSX элементу передается проп ref, React связывает DOM-узел со свойством ref.current. Узел записывается туда после монтирования в DOM, а при размонтировании сбрасывается в null.
Чтобы перевести фокус на поле ввода сразу после появления формы на экране, вызов метода focus() выполняют внутри эффекта, реагирующего на изменение флага видимости:
import { useEffect, useRef } from 'react';
function FeedbackModal({ isOpen, onClose }) {
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) {
// DOM-узел уже примонтирован, метод focus() безопасен
inputRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) {
return null;
}
return (
<div className="modal">
<h2>Обратная связь</h2>
<input
ref={inputRef}
type="text"
placeholder="Ваше имя"
/>
<button onClick={onClose}>Закрыть</button>
</div>
);
}
Опциональная цепочка inputRef.current?.focus() защищает от ошибок в ситуациях, когда размонтирование компонента или смена условий отображения произошли до срабатывания эффекта.
Измерение элементов и задержка отрисовки
Еще один частый сценарий обращения к DOM — вычисление физических размеров и координат элементов через getBoundingClientRect().
Разделение ответственности между ref и state
Получить размеры элемента можно только после того, как браузер выполнит расчет стилей и компоновку (layout). Здесь возникает связка двух хуков:
useRefдает доступ к DOM-узлу.useStateсохраняет результат замера, если эти цифры должны быть отрисованы в интерфейсе или переданы в стили других компонентов.
Если сохранить ширину и высоту просто внутри ref.current, компонент не перерисуется, и интерфейс останется в исходном виде.
Устранение мигания с помощью useLayoutEffect
Если измерить элемент внутри стандартного useEffect, пользователь может заметить скачок интерфейса (Layout Shift). useEffect выполняется асинхронно после того, как браузер уже отрисовал кадр на экране. Если внутри эффекта вызывается setState, браузер сначала рисует интерфейс без учета размеров, а затем немедленно перерисовывает его с обновленным состоянием.
Чтобы избежать визуального мерцания, замеры геометрии, влияющие на начальную отрисовку, выполняют в useLayoutEffect. Он отрабатывает синхронно до того, как браузер выведет пиксели на экран:
import { useLayoutEffect, useRef, useState } from 'react';
function TooltipTarget() {
const targetRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
if (targetRef.current) {
const rect = targetRef.current.getBoundingClientRect();
setDimensions({
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}, []);
return (
<div>
<div ref={targetRef} className="card">
Блок с динамическим контентом
</div>
<p>Размеры блока: {dimensions.width}px × {dimensions.height}px</p>
</div>
);
}
Хранение значений между рендерами: кейс с таймером
useRef не ограничен хранением DOM-элементов. В нем можно держать идентификаторы системных таймеров, экземпляры сторонних библиотек, сокет-соединения или предыдущие значения пропсов.
Ошибки при выборе хранилища для таймера
При реализации таймера разработчики часто совершают две диаметрально противоположные ошибки:
- Хранение идентификатора в
useState: При вызовеsetIntervalбраузер возвращает числовой ID. Если записать его вstate, компонент выполнит дополнительный рендер. При этом визуально на экране ничего не меняется: ID таймера пользователю не нужен. - Хранение идентификатора в обычной переменной:
- Если объявить
let timerIdвнутри функции компонента, переменная будет сбрасываться вundefinedпри каждом новом рендере. Ссылка на интервал потеряется, и остановить его черезclearInterval(timerId)станет невозможно. - Если объявить
let timerIdвне компонента (на уровне модуля), переменная станет общей для всех копий компонента на странице. Если на экране появится два независимых секундомера, один перетрет идентификатор другого.
- Если объявить
Корректная реализация секундомера
useRef изолирует значение внутри конкретного экземпляра компонента и сохраняет его неизменным на протяжении всей жизни узла:
import { useEffect, useRef, useState } from 'react';
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const timerIdRef = useRef(null);
const start = () => {
if (timerIdRef.current !== null) return;
setIsRunning(true);
timerIdRef.current = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
};
const stop = () => {
if (timerIdRef.current === null) return;
clearInterval(timerIdRef.current);
timerIdRef.current = null;
setIsRunning(false);
};
const reset = () => {
stop();
setSeconds(0);
};
// Очистка таймера при размонтировании компонента для предотвращения утечки памяти
useEffect(() => {
return () => {
if (timerIdRef.current !== null) {
clearInterval(timerIdRef.current);
}
};
}, []);
return (
<div>
<h3>Прошло времени: {seconds} с</h3>
<button onClick={start} disabled={isRunning}>Старт</button>
<button onClick={stop} disabled={!isRunning}>Стоп</button>
<button onClick={reset}>Сброс</button>
</div>
);
}
Опасная зона: чтение и запись ref.current во время рендера
React делит свою работу на две основные фазы:
- Фаза рендера (Render phase): вызов функций компонентов, вычисление JSX, сравнение с предыдущим виртуальным деревом. Эта фаза должна оставаться чистой функцией без побочных эффектов.
- Фаза фиксации (Commit phase): применение изменений к реальному DOM, запуск эффектов (
useEffect,useLayoutEffect).
Почему нельзя читать и менять ref прямо в теле компонента
Чтение или запись ref.current непосредственно в теле функции компонента (вне хуков эффектов и функций-обработчиков событий) нарушает принцип чистоты функций.
// АНТИПАТТЕРН: запись и чтение во время рендера
function Counter() {
const renderCount = useRef(0);
// Мутация во время фазы рендера:
renderCount.current += 1;
// Чтение во время фазы рендера:
return <h1>Рендеров: {renderCount.current}</h1>;
}
Последствия такого кода:
- Непредсказуемость в Strict Mode: В режиме разработки React намеренно вызывает рендер дважды, чтобы проверить компоненты на чистоту. В результате счетчик увеличится на 2 за один реальный шаг.
- Сломанный Concurrent Mode: React может начать рендерить компонент, приостановить этот процесс ради более приоритетной задачи, отбросить черновой результат и начать заново. Мутация
ref.currentпри этом не откатывается, что приводит к рассинхронизации данных. - Устаревший UI: Если
ref.currentизменился в фазе рендера, но само состояние компонента не изменилось, React не обновит JSX на экране при следующем проходе.
Где разрешено работать с ref.current
Безопасными зонами для взаимодействия со свойствами ref являются:
- Обработчики событий (Event Handlers): клики, сабмиты, перемещения курсора (выполняются в ответ на действия пользователя).
- Эффекты (
useEffect,useLayoutEffect): гарантированно запускаются уже после завершения фазы рендера и фиксации изменений в DOM.
Если значение должно вычисляться прямо в процессе рендера и не зависит от DOM или побочных эффектов, используйте обычные переменные или хук useMemo.
Частые вопросы (FAQ)
1. Вызовет ли изменение ref.current = newValue обновление компонента?
Нет. Мутация свойства current — это изменение обычного JavaScript-объекта. React не отслеживает внутренние поля этого объекта и не запускает механизм повторного рендера.
2. Почему нельзя просто объявить переменную let timerId внутри компонента?
При каждом рендере функция компонента выполняется заново от начала до конца. Все локальные переменные инициализируются с нуля, а значения из предыдущего цикла рендера уничтожаются сборщиком мусора.
3. Можно ли передать ref в собственный функциональный компонент?
В версиях React до 19 передать ref напрямую в функциональный компонент через стандартный проп нельзя — для этого требуется оборачивать дочерний компонент в forwardRef. Начиная с React 19, ref доступен как обычный проп без использования дополнительных функций-оберток.
4. Когда для снятия размеров узла нужен useLayoutEffect, а когда достаточно useEffect?
Если замер нужен для синхронного позиционирования всплывающего меню, тултипа или модального окна и его результат влияет на первый отображаемый кадр — используйте useLayoutEffect. Это исключит мерцание. Если размеры снимаются для фоновой аналитики или отправки метрик на сервер, используйте стандартный useEffect.
5. Что попадает в ref.current, если элемент скрыт через условие {isVisible && <div ref={myRef} />}?
Когда условие ложно, элемент удаляется из DOM-дерева. React автоматически присваивает myRef.current значение null. При повторном появлении узла поле снова заполняется ссылкой на созданный DOM-элемент.
6. Можно ли использовать useRef вместо useState для оптимизации производительности?
Только если эти данные не выводятся в JSX. Замена useState на useRef для отображаемых данных приведет к тому, что интерфейс перестанет синхронизироваться с актуальным состоянием приложения.
Резюме: шпаргалка по использованию useRef
- Используйте
useRef, когда нужно сохранить значение между рендерами, но его обновление не должно провоцировать перерисовку UI. - Для взаимодействия с DOM (фокус, скролл, замеры) привязывайте
refчерез одноименный атрибут узла и обращайтесь к нему только после монтирования. - Если результат замера DOM-элемента должен влиять на визуал или позиционирование, переносите данные из
ref.currentвuseState. - Очищайте любые внешние ресурсы (интервалы, слушатели событий, сторонние инстансы), хранящиеся в
ref, в функции очистки хукаuseEffect. - Никогда не мутируйте и не читайте
ref.currentнепосредственно в теле функции компонента во время фазы рендера. Делайте это только в эффектах или обработчиках событий.



.svg.webp)




