Ссылки на узлы: useRef и ref как пропс

эффекты≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В статье про неуправляемые поля ref был способом дотянуться до узла из обработчика: useRef<HTMLInputElement>(null), ref={input} — и после коммита в current лежит настоящий <input>. Осталось то, без чего ref остаётся фокусом: в какой момент узел попадает в current и когда оттуда исчезает, как читать из него размеры — то, что в части II вы делали через getBoundingClientRect, — и как отдать ref внутрь своего компонента. В React 19 последнее стало обычным пропсом, и forwardRef для этого больше не нужен.

Разделы темы:·

В той статье вы видели два состояния: в рендере current пуст, в обработчике — узел. Точнее так: React кладёт узел в current на стадии коммита, до того как вызовет хоть один эффект, — именно это позволяет эффектам работать с DOM. А когда элемент уходит со страницы, React сам возвращает в current значение null. Компонент ниже прячет поле по кнопке; прогноз в четыре строки: что напечатают рендер и эффект до клика и после него.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третья строка — та, на которой ошибаются. Во второй отрисовке open уже false, поле в разметку не попало, а в current всё ещё лежит <input>. Ref меняется не в рендере, а в коммите, и в теле компонента вы видите то, что оставил прошлый коммит: до первого — null, до следующего — старый узел. Это вторая причина не читать current во время рендера, в дополнение к чистоте рендера из статьи про триггер, рендер и коммит: значение там не просто ненадёжное — оно из другой отрисовки.

Четвёртая строка — обещание React: элемент снят со страницы, и current обнулён. Эффект, который читает ref, не наткнётся на узел, которого на экране уже нет; тип HTMLInputElement | null говорит правду не только про первый рендер.

Самый частый эффект с ref — фокус при появлении. В статье про неуправляемые поля фокус ставил обработчик клика; когда поле должно получить курсор сразу, как только оказалось на экране, это эффект в точном смысле статьи о синхронизации: фокус — состояние браузера, а не компонента, и «пока форма на экране, курсор должен стоять в имени» — описание внешнего мира.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

field.current не бывает зависимостью эффекта: React сравнивает массив во время рендера, когда в current лежит прошлый коммит. [field.current] ни разу не заметит, что узел сменился.

Через ref до узла можно не только дотянуться, но и изменить его — например, вписать текст в абзац, который рисует React. Кажется, что следующая перерисовка вернёт всё как было. Пример вписывает текст руками, затем перерисовывает компонент; предскажите, что окажется в абзаце.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Текст остался вашим. Коммит записывает в DOM только разницу между старым и новым описанием, а описание абзаца не менялось — «Текст от React» в обеих отрисовках, — значит, React к узлу не прикасался. Это не гарантия, а следствие, и оно опасно: как только текст абзаца станет зависеть от состояния, React перепишет его, а до того на экране будет висеть то, чего в вашей модели нет. Правило: через ref трогают то, чего React не описывает, — фокус, прокрутку, выделение, размеры, сторонний виджет в пустом div. Всё, что есть в JSX, меняют только через состояние.

Размеры есть только у узла на странице. В части II вы вызывали getBoundingClientRect сразу после того, как собрали разметку; здесь разметку собирает React на стадии коммита, и первое место, где можно мерить, — эффект. Если ширина нужна только в обработчике — например, чтобы решить, куда прокрутить, — читайте её там же, без состояния. Но если она участвует в разметке, ей нужно состояние, а состоянию — сеттер. Предскажите, сколько раз напечатается «рендер» и каким будет флаг измерения в первой строке. Само число в пикселях пример не печатает нарочно: ширина текста зависит от шрифта платформы, а урок — в моменте измерения, а не в сантиметрах.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две отрисовки, и это тот случай, когда вторая честная. В статье «Когда эффект не нужен» сумма корзины считалась в теле компонента, потому что была выводима из состояния до всякого DOM. Ширина текста не выводима ни из чего: пока браузер не разложил строку шрифтом, числа не существует. Эффект здесь не поддерживает производное значение, а приносит в компонент то, что знает только браузер.

Плата — кадр с нулём: первая отрисовка закоммичена и может быть показана до того, как эффект запустится. Для короткой надписи это незаметно. Но если по измерению вычисляется положение — подсказка над кнопкой или под ней, — пользователь увидит прыжок. Для таких случаев есть useLayoutEffect.

Сигнатура та же, что у useEffect, отличие в моменте: React вызывает его сразу после коммита, синхронно, и обновление состояния из него применяет до того, как отдаст управление браузеру. Кадра с нулём не бывает — браузер видит уже вторую отрисовку. В консоли разницы нет; она в том, чего не увидит глаз.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Цена — браузер ждёт ваш код: пока layout-эффект и вызванная им отрисовка не закончатся, страница не обновится. Поэтому в нём делают ровно одно: измерить и переставить. Подписки, запросы, таймеры остаются в useEffect. Если сомневаетесь, какой нужен, — нужен useEffect: мигание вы заметите, а замедление от лишнего useLayoutEffect — нет.

Где выполняется кодЧто лежит в current
Тело компонентато, что оставил прошлый коммит: null до первого, старый узел — до следующего
Обработчик событияузел последнего коммита
useEffectузел; если элемент снят с экрана — null
useLayoutEffectто же, но до того, как браузер нарисует кадр
ref-функцияузел в момент коммита — последний раздел

У useRef в типах React 19 три перегрузки, и все три требуют аргумент: useRef<T>(initialValue: T) даёт RefObject<T>, useRef<T>(initialValue: T | null) — RefObject<T | null>, третья — то же с undefined. Перегрузки без аргумента, которая в React 18 возвращала MutableRefObject<T | undefined>, больше нет; сам MutableRefObject в типах остался, но помечен устаревшим в пользу RefObject — у того current и так просто записываемое поле. Три ref ниже, два из них написаны неправильно. Предскажите, какие строки компилятор отметит и что скажет.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три диагностики. Строка 2 — «Ожидалось аргументов: 1, получено: 0»: ref под узел всегда начинается с null, и это null нужно написать. Строка 7 — у HTMLElement нет value: общий предок всех элементов знает только то, что есть у каждого, и чтобы читать значение поля, нужен именно HTMLInputElement. Строка 13 — обратная сторона: RefObject<HTMLElement | null> нельзя отдать в ref у <input>. Элемент ждёт Ref<HTMLInputElement>, а current — записываемое поле, и структурная проверка из части III требует совпадения, а не «чего-то похожего»; компилятор даже перечислит, чего у HTMLElement не хватает: accept, align, alt, autocomplete «и ещё 55».

Правило одно: параметр типа — интерфейс того элемента, на котором стоит ref. Эти интерфейсы вы знаете с части II: HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement, HTMLButtonElement, HTMLDivElement, HTMLCanvasElement. Сомневаетесь, какой у тега интерфейс, — наведите курсор на document.createElement("span"): возвращаемый тип и есть ответ.

Для значений, а не узлов, аргумент даёт тип сам: useRef(0) — это RefObject<number>, куда можно писать; номер таймера из статьи про неуправляемые поля хранился в useRef<number | null>(null). Ниже — три типа, которые встретятся в сигнатурах вашего кода и чужих библиотек.

ТипЧто этоГде встречается
RefObject<T>объект с одним полем current: Tрезультат useRef
RefCallback<T>функция (node: T | null) => void | (() => void)ref-функция — последний раздел
Ref<T>RefCallback<T> | RefObject<T | null> | nullтип пропса ref: то, что принимает элемент — и ваш компонент

Родитель хочет поставить фокус в поле, которое спрятано внутри вашего TextField. До React 19 ref не был пропсом: React вынимал его из атрибутов до вызова функции, компонент получал props без него, и чтобы протащить ref внутрь, компонент оборачивали в forwardRef. В React 19 ref приходит в props как любое другое поле: вы объявляете его в интерфейсе и ставите на нужный элемент. Предскажите, что напечатает эффект родителя.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В current — <input>, а не <label> и не «компонент». У функции-компонента нет узла: ref всегда оказывается на том элементе, на который его поставили внутри, и это решает автор компонента. Поставит на label — родитель получит label. Тип React.Ref<HTMLInputElement> из таблицы выше принимает и объект из useRef, и функцию — ровно то, что принял бы сам <input>.

А если ref в интерфейсе не объявлен? Тогда это обычная ошибка в пропсах — та же, что title={42} в статье про пропсы. Предскажите формулировку.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Свойство "ref" не существует в типе "IntrinsicAttributes & TextFieldProps"». В IntrinsicAttributes — то, что можно передать любому компоненту без объявления, и в React 19 там остался один key. Во время выполнения ничего не происходит: компонент получил лишний проп и не воспользовался им, в current так и лежит null. Договор о том, что компонент отдаёт наружу узел, живёт в его интерфейсе — и только там.

Когда компонент — обёртка над одним элементом, перечислять его пропсы вручную незачем: React.ComponentProps<"input"> даёт всё, что принимает <input>, включая ref. Остаток после деструктуризации уезжает на элемент целиком, и ref едет вместе с type и placeholder — потому что он теперь такой же проп, как они.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

key остался особым: он по-прежнему не попадает в props. Особым был и ref — теперь нет.

Старый код и большинство библиотек ещё написаны через forwardRef: компонент оборачивается в вызов, а ref приходит вторым аргументом функции. В React 19 это работает, но объявлено уходящим — в будущих версиях обёртку уберут, а для перевода есть кодмод. Узнавать её нужно, писать — нет.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Иногда отдавать родителю весь узел не хочется: ему нужно «поставить фокус» и «очистить», а не доступ к style и innerHTML. useImperativeHandle подменяет то, что попадёт в родительский current: вместо узла — объект с методами, которые вы выбрали. Тип пропса тогда — Ref не от элемента, а от вашего интерфейса.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Нужен он редко: если родителю приходится командовать дочерним компонентом, чаще это признак, что состояние стоит поднять выше, — тема следующей главы. Но для фокуса, прокрутки и воспроизведения видео команда уместнее состояния, и useImperativeHandle даёт ей узкий, типизированный вход.

В ref можно положить не объект, а функцию — это RefCallback из таблицы. React вызывает её с узлом в момент коммита и с null, когда узел уходит; ни useRef, ни эффект для этого не нужны. Удобно, когда что-то надо сделать ровно в момент появления узла: подключить наблюдатель, прокрутить к только что добавленной строке. Но у стрелки, написанной прямо в разметке, есть свойство, знакомое по объекту в зависимостях из статьи о зависимостях: на каждой отрисовке это новая функция. Предскажите, сколько строк напечатает ref после одного клика.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Абзац не менялся, а ref после клика сработал дважды. React сравнивает старую и новую функцию по ссылке; они разные — значит, ref сменился: старому нужно сообщить, что узла у него больше нет (null), новому — вручить узел. Для console.log это безобидно. Для наблюдателя, который в этой функции подключается, — утечка: подписка на каждой отрисовке, отписки нигде.

Отписку React 19 сделал частью договора: ref-функция может вернуть функцию уборки, и тогда при смене ref или уходе узла React вызовет её — а вызов с null в этом случае отменяется. Пара «подключить — убрать» выглядит как эффект, только привязана к узлу, а не к отрисовке. Предскажите вывод после клика.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Уборка сработала до нового подключения, утечки нет. Осталось лишнее переподключение на каждой отрисовке — потому что watch по-прежнему создаётся в теле компонента. Если функции не нужны данные компонента, её просто выносят наружу: у функции на уровне модуля ссылка одна на всю жизнь, и React не увидит смены ref.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Когда функции нужны данные компонента — сеттер, id строки, — вынести её нельзя, и стабильную ссылку дают useCallback или React Compiler; это глава про скорость. До неё держитесь правила: ref-функция с уборкой, и лишнее переподключение вам не страшно, только некрасиво. А для списка, где узел нужен у каждой строки, ref-функция — единственный удобный способ: useRef на строку не заведёшь, хуки в цикле не вызывают. Такая задача ждёт ниже.

Объект из useRef — когда узел нужен потом, в обработчике или эффекте. Функция — когда что-то надо сделать в момент, когда узел появился, и отменить, когда исчез.

  • Узел попадает в current на стадии коммита, до эффектов, и возвращается к null, когда элемент уходит со страницы. В теле компонента виден прошлый коммит, поэтому current там не читают.
  • Мерить размеры можно только после коммита: в эффекте — с честной второй отрисовкой, в useLayoutEffect — до того, как браузер покажет кадр; последний только для «измерить и переставить».
  • Параметр типа useRef — интерфейс именно того элемента, где стоит ref, а аргумент null обязателен: перегрузки без аргумента в React 19 нет.
  • В React 19 ref — обычный проп: объявите ref?: React.Ref<HTMLInputElement> или наследуйте React.ComponentProps<"input">, а forwardRef оставьте старому коду.
  • Необъявленный ref — обычная ошибка в пропсах, которую ловит компилятор; во время выполнения компонент его просто не использует.
  • Ref-функция вызывается с узлом при появлении и может вернуть уборку; стрелка в разметке — новая функция на каждой отрисовке, и React честно переподключает её каждый раз.

1/4Компонент хранит ref на <input>, который рисуется по условию. Пользователь спрятал поле, и React вызвал функцию компонента заново. Что лежит в current в этой, второй отрисовке — прямо в теле компонента?

2/4В типах React 19 написано const box = React.useRef<HTMLDivElement>() — без аргумента. Что скажет компилятор?

3/4Функция-компонент Card({ title }: CardProps) не объявляет ref в пропсах. Родитель пишет <Card title="…" ref={cardRef} />. Что произойдёт в React 19?

4/4В разметке стоит <p ref={(node) => console.log(node)}>, уборку стрелка не возвращает. Компонент перерисовался один раз из-за не связанного с абзацем состояния. Сколько раз стрелка сработает при этой перерисовке?

Фокус на первое пустое поле

Форма из трёх неуправляемых полей. При отправке нужно найти первое пустое поле и поставить в него фокус; если все заполнены — напечатать «отправляем». Заведите ref на форму и ищите поля внутри неё: querySelectorAll по узлу из ref — это поиск в границах вашего компонента, а не по всей странице. Пример заполняет только второе поле и отправляет форму.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ref на форму типизируется как HTMLFormElement; querySelectorAll("input") по имени тега возвращает NodeListOf<HTMLInputElement> — компилятор знает интерфейс по тегу, и приведение не нужно. Массив из части I, find — и фокус.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Здесь подошёл бы и e.currentTarget — форма приходит в событии сама. Ref выигрывает, когда фокус ставится не из события формы: например, по кнопке «проверить» снаружи или из эффекта после загрузки данных.

Компонент, который отдаёт узел

SearchBox рисует подпись, поле и кнопку очистки. Родителю нужно ставить фокус в поле, но сейчас ref в пропсах компонента нет, и компилятор это замечает — а следом пожалуется и на placeholder: лишние свойства он называет по одному. Сделайте так, чтобы компонент принимал ref и все пропсы <input> без forwardRef.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Интерфейс наследует React.ComponentProps<"input"> — там и placeholder, и ref. Свои пропсы вынимаются деструктуризацией, остаток целиком уезжает на <input>.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если понадобится и очищать поле по кнопке, и оставлять его неуправляемым, кнопке нужен свой доступ к узлу. Тогда внутри заводят собственный useRef, а внешний ref и внутренний нельзя поставить на один элемент одновременно — на выручку приходит ref-функция, которая кладёт узел в оба, или useImperativeHandle из свёрнутого раздела.

Прокрутить к выбранной строке

Список из сорока строк не помещается в кадр. При смене выбранного номера нужно прокручивать список так, чтобы строка оказалась на виду, — scrollIntoView из части II. Узел нужен у каждой строки, а useRef в цикле не вызвать: соберите узлы в Map через ref-функцию с уборкой, а прокрутку сделайте эффектом, зависящим от selected. Пример выбирает тридцать пятую строку.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ref-функция принимает номер строки и возвращает RefCallback для неё; уборка убирает строку из Map, когда узел уходит. Эффект с зависимостью [selected] находит узел и прокручивает к нему — после коммита, когда строка уже на странице.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

remember(row) создаёт новую функцию на каждой отрисовке, и React на каждой отрисовке убирает и снова кладёт узел в Map. Для сорока строк это незаметно, а стабильную ссылку даст useCallback из главы про скорость. Заметьте, что Map лежит в ref, а не в состоянии: он не участвует в разметке, и перерисовывать из-за него нечего.