Ссылки на узлы: useRef и ref как пропс
Здесь будет встроен видео-разбор темы (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. Компонент ниже прячет поле по кнопке; прогноз в четыре строки: что напечатают рендер и эффект до клика и после него.
Третья строка — та, на которой ошибаются. Во второй отрисовке open уже false, поле в разметку не попало, а в current всё ещё лежит <input>. Ref меняется не в рендере, а в коммите, и в теле компонента вы видите то, что оставил прошлый коммит: до первого — null, до следующего — старый узел. Это вторая причина не читать current во время рендера, в дополнение к чистоте рендера из статьи про триггер, рендер и коммит: значение там не просто ненадёжное — оно из другой отрисовки.
Четвёртая строка — обещание React: элемент снят со страницы, и current обнулён. Эффект, который читает ref, не наткнётся на узел, которого на экране уже нет; тип HTMLInputElement | null говорит правду не только про первый рендер.
Самый частый эффект с ref — фокус при появлении. В статье про неуправляемые поля фокус ставил обработчик клика; когда поле должно получить курсор сразу, как только оказалось на экране, это эффект в точном смысле статьи о синхронизации: фокус — состояние браузера, а не компонента, и «пока форма на экране, курсор должен стоять в имени» — описание внешнего мира.
field.current не бывает зависимостью эффекта: React сравнивает массив во время рендера, когда в current лежит прошлый коммит. [field.current] ни разу не заметит, что узел сменился.
Через ref до узла можно не только дотянуться, но и изменить его — например, вписать текст в абзац, который рисует React. Кажется, что следующая перерисовка вернёт всё как было. Пример вписывает текст руками, затем перерисовывает компонент; предскажите, что окажется в абзаце.
Текст остался вашим. Коммит записывает в DOM только разницу между старым и новым описанием, а описание абзаца не менялось — «Текст от React» в обеих отрисовках, — значит, React к узлу не прикасался. Это не гарантия, а следствие, и оно опасно: как только текст абзаца станет зависеть от состояния, React перепишет его, а до того на экране будет висеть то, чего в вашей модели нет. Правило: через ref трогают то, чего React не описывает, — фокус, прокрутку, выделение, размеры, сторонний виджет в пустом div. Всё, что есть в JSX, меняют только через состояние.
Размеры есть только у узла на странице. В части II вы вызывали getBoundingClientRect сразу после того, как собрали разметку; здесь разметку собирает React на стадии коммита, и первое место, где можно мерить, — эффект. Если ширина нужна только в обработчике — например, чтобы решить, куда прокрутить, — читайте её там же, без состояния. Но если она участвует в разметке, ей нужно состояние, а состоянию — сеттер. Предскажите, сколько раз напечатается «рендер» и каким будет флаг измерения в первой строке. Само число в пикселях пример не печатает нарочно: ширина текста зависит от шрифта платформы, а урок — в моменте измерения, а не в сантиметрах.
Две отрисовки, и это тот случай, когда вторая честная. В статье «Когда эффект не нужен» сумма корзины считалась в теле компонента, потому что была выводима из состояния до всякого DOM. Ширина текста не выводима ни из чего: пока браузер не разложил строку шрифтом, числа не существует. Эффект здесь не поддерживает производное значение, а приносит в компонент то, что знает только браузер.
Плата — кадр с нулём: первая отрисовка закоммичена и может быть показана до того, как эффект запустится. Для короткой надписи это незаметно. Но если по измерению вычисляется положение — подсказка над кнопкой или под ней, — пользователь увидит прыжок. Для таких случаев есть useLayoutEffect.
Сигнатура та же, что у useEffect, отличие в моменте: React вызывает его сразу после коммита, синхронно, и обновление состояния из него применяет до того, как отдаст управление браузеру. Кадра с нулём не бывает — браузер видит уже вторую отрисовку. В консоли разницы нет; она в том, чего не увидит глаз.
Цена — браузер ждёт ваш код: пока 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 ниже, два из них написаны неправильно. Предскажите, какие строки компилятор отметит и что скажет.
Три диагностики. Строка 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 как любое другое поле: вы объявляете его в интерфейсе и ставите на нужный элемент. Предскажите, что напечатает эффект родителя.
В current — <input>, а не <label> и не «компонент». У функции-компонента нет узла: ref всегда оказывается на том элементе, на который его поставили внутри, и это решает автор компонента. Поставит на label — родитель получит label. Тип React.Ref<HTMLInputElement> из таблицы выше принимает и объект из useRef, и функцию — ровно то, что принял бы сам <input>.
А если ref в интерфейсе не объявлен? Тогда это обычная ошибка в пропсах — та же, что title={42} в статье про пропсы. Предскажите формулировку.
«Свойство "ref" не существует в типе "IntrinsicAttributes & TextFieldProps"». В IntrinsicAttributes — то, что можно передать любому компоненту без объявления, и в React 19 там остался один key. Во время выполнения ничего не происходит: компонент получил лишний проп и не воспользовался им, в current так и лежит null. Договор о том, что компонент отдаёт наружу узел, живёт в его интерфейсе — и только там.
Когда компонент — обёртка над одним элементом, перечислять его пропсы вручную незачем: React.ComponentProps<"input"> даёт всё, что принимает <input>, включая ref. Остаток после деструктуризации уезжает на элемент целиком, и ref едет вместе с type и placeholder — потому что он теперь такой же проп, как они.
key остался особым: он по-прежнему не попадает в props. Особым был и ref — теперь нет.
Старый код и большинство библиотек ещё написаны через forwardRef: компонент оборачивается в вызов, а ref приходит вторым аргументом функции. В React 19 это работает, но объявлено уходящим — в будущих версиях обёртку уберут, а для перевода есть кодмод. Узнавать её нужно, писать — нет.
Иногда отдавать родителю весь узел не хочется: ему нужно «поставить фокус» и «очистить», а не доступ к style и innerHTML. useImperativeHandle подменяет то, что попадёт в родительский current: вместо узла — объект с методами, которые вы выбрали. Тип пропса тогда — Ref не от элемента, а от вашего интерфейса.
Нужен он редко: если родителю приходится командовать дочерним компонентом, чаще это признак, что состояние стоит поднять выше, — тема следующей главы. Но для фокуса, прокрутки и воспроизведения видео команда уместнее состояния, и useImperativeHandle даёт ей узкий, типизированный вход.
В ref можно положить не объект, а функцию — это RefCallback из таблицы. React вызывает её с узлом в момент коммита и с null, когда узел уходит; ни useRef, ни эффект для этого не нужны. Удобно, когда что-то надо сделать ровно в момент появления узла: подключить наблюдатель, прокрутить к только что добавленной строке. Но у стрелки, написанной прямо в разметке, есть свойство, знакомое по объекту в зависимостях из статьи о зависимостях: на каждой отрисовке это новая функция. Предскажите, сколько строк напечатает ref после одного клика.
Абзац не менялся, а ref после клика сработал дважды. React сравнивает старую и новую функцию по ссылке; они разные — значит, ref сменился: старому нужно сообщить, что узла у него больше нет (null), новому — вручить узел. Для console.log это безобидно. Для наблюдателя, который в этой функции подключается, — утечка: подписка на каждой отрисовке, отписки нигде.
Отписку React 19 сделал частью договора: ref-функция может вернуть функцию уборки, и тогда при смене ref или уходе узла React вызовет её — а вызов с null в этом случае отменяется. Пара «подключить — убрать» выглядит как эффект, только привязана к узлу, а не к отрисовке. Предскажите вывод после клика.
Уборка сработала до нового подключения, утечки нет. Осталось лишнее переподключение на каждой отрисовке — потому что watch по-прежнему создаётся в теле компонента. Если функции не нужны данные компонента, её просто выносят наружу: у функции на уровне модуля ссылка одна на всю жизнь, и React не увидит смены ref.
Когда функции нужны данные компонента — сеттер, 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 — это поиск в границах вашего компонента, а не по всей странице. Пример заполняет только второе поле и отправляет форму.
Ref на форму типизируется как HTMLFormElement; querySelectorAll("input") по имени тега возвращает NodeListOf<HTMLInputElement> — компилятор знает интерфейс по тегу, и приведение не нужно. Массив из части I, find — и фокус.
Здесь подошёл бы и e.currentTarget — форма приходит в событии сама. Ref выигрывает, когда фокус ставится не из события формы: например, по кнопке «проверить» снаружи или из эффекта после загрузки данных.
Компонент, который отдаёт узел
SearchBox рисует подпись, поле и кнопку очистки. Родителю нужно ставить фокус в поле, но сейчас ref в пропсах компонента нет, и компилятор это замечает — а следом пожалуется и на placeholder: лишние свойства он называет по одному. Сделайте так, чтобы компонент принимал ref и все пропсы <input> без forwardRef.
Интерфейс наследует React.ComponentProps<"input"> — там и placeholder, и ref. Свои пропсы вынимаются деструктуризацией, остаток целиком уезжает на <input>.
Если понадобится и очищать поле по кнопке, и оставлять его неуправляемым, кнопке нужен свой доступ к узлу. Тогда внутри заводят собственный useRef, а внешний ref и внутренний нельзя поставить на один элемент одновременно — на выручку приходит ref-функция, которая кладёт узел в оба, или useImperativeHandle из свёрнутого раздела.
Прокрутить к выбранной строке
Список из сорока строк не помещается в кадр. При смене выбранного номера нужно прокручивать список так, чтобы строка оказалась на виду, — scrollIntoView из части II. Узел нужен у каждой строки, а useRef в цикле не вызвать: соберите узлы в Map через ref-функцию с уборкой, а прокрутку сделайте эффектом, зависящим от selected. Пример выбирает тридцать пятую строку.
Ref-функция принимает номер строки и возвращает RefCallback для неё; уборка убирает строку из Map, когда узел уходит. Эффект с зависимостью [selected] находит узел и прокручивает к нему — после коммита, когда строка уже на странице.
remember(row) создаёт новую функцию на каждой отрисовке, и React на каждой отрисовке убирает и снова кладёт узел в Map. Для сорока строк это незаметно, а стабильную ссылку даст useCallback из главы про скорость. Заметьте, что Map лежит в ref, а не в состоянии: он не участвует в разметке, и перерисовывать из-за него нечего.