Refs в React: от доступа к DOM до императивного API

Одна из полезных особенностей React  —  абстрагирование от сложного взаимодействия с объектной моделью документа (DOM, Document Object Model). Вместо того чтобы запрашивать элементы, пытаться добавить к ним классы и устранять несоответствия браузера, можно просто писать компоненты, сосредоточившись на взаимодействии с пользователем. Однако иногда все же появляется необходимость в доступе к реальному DOM.

И здесь самое важное  —  разобраться в особенностях Ref и его окружения, научиться их правильно использовать. Сначала рассмотрим, зачем нужен доступ к DOM, как при этом помогает Ref, что такое useRef, forwardRef и useImperativeHandle и как их правильно использовать. Кроме того, посмотрим, как без forwardRef и useImperativeHandle получить подобные результаты.

А в качестве бонуса рассмотрим реализацию в React императивных API.

Доступ к DOM в React с помощью useRef

Допустим, нужно создать форму регистрации участников конференции. Они должны оставлять свое имя и другие данные, прежде чем им отправят дополнительную информацию. Обязательными будут поля «имя» и «электронная почта». Мы не будем добавлять раздражающие эффекты, возникающие при попытке оставить их пустыми, например красные рамки. Вместо этого пустое поле будет фокусироваться и немного трястись, чтобы ненавязчиво привлечь внимание.

Сегодня React может многое, но не все. В пакете нет таких конструкций, как «сфокусировать элемент вручную». Тут и понадобятся навыки работы с API Javascript. А для этого нужен доступ к фактическому элементу DOM.

Без React можно сделать что-то подобное:

const element = document.getElementById("bla");

Затем элемент можно сфокусировать:

element.focus();

Или прокрутить:

element.scrollIntoView();

Можно сделать и нечто вроде нативного API DOM в React:

  • ручная фокусировка элемента после его рендеринга, например поля ввода в форме;
  • обнаружение щелчка за пределами компонента при отображении всплывающих окон;
  • ручная прокрутка к элементу после его появления на экране;
  • вычисление размеров и границ компонентов на экране для правильного позиционирования, к примеру, всплывающей подсказки.

И хотя технически ничто не мешает использовать getElementById, React предоставляет и несколько более мощный способ получить доступ к этому элементу, который не потребует повсеместного распределения идентификаторов и знания базовой структуры DOM . Это рефы.

Ref  —  это просто изменяемый объект, ссылку на который React сохраняет между повторными рендерингами. Он не запускает повторный рендеринг, поэтому никак не меняет состояние. Подробнее о различии между Ref и State написано здесь.

Ref создается с помощью хука useRef:

const  Component = ( ) => { 
// создаем ref со значением null по умолчанию
const ref = useRef (null);

return...
}

Значение, хранящееся в Ref, будет доступно в «текущем» (и единственном) его свойстве. В действительности хранить в нем можно что угодно! Например, объект с некоторыми значениями, поступающими из состояния:

const  Component = ( ) => { 
const ref = useRef (null);

useEffect ( () => {
// перезаписываем значение ref по умолчанию с новым объектом
ref.current = {
someFunc: () => {...},
someValue : stateValue,
}
}, [stateValue])

return ...
}

Или, что более важно для нашего случая, можно назначить этот Ref любому элементу DOM и некоторым компонентам React:

const  Component = ( ) => { 
const ref = useRef (null);

// присвоение ref элементу ввода
return <input ref={ref} />
}

Теперь, если регистрировать ref.current в useEffect (это возможно только после рендеринга компонента), мы увидим точно такой же элемент, который получился бы, если реализовать getElementById на этом входе:

const  Component = ( ) => { 
const ref = useRef (null);

useEffect(() =>{
// это будет ссылка на входной DOM-элемент!
// точно так же, как если бы реализовать для него getElementById
console.log(ref.current);
});

return <input ref={ref} /> }

Теперь, если реализовать регистрационную форму как один большой компонент, можно сделать что-то подобное:

const  Form = ( ) => { 
const [name, setName] = useState ( '' );
const inputRef = useRef (null);

const onSubmitClick = ( ) => {
if (!name) {
// "фокусируем" поле ввода, если кто-то пытается отправить незаполненное поле имени
ref.current.focus();
} else {
// отправляем данные сюда!
}
}

return <>
...
<input onChange={(e) => setName(e.target.value)} ref={ref} />
<button onClick={onSubmitClick}>Отправить форму!</button>
</>
}

Сохраните значения из входов в состояние, создайте “рефы” для всех входов, а после нажатия кнопки “submit” нужно проверить заполненность полей ввода и при необходимости “сфокусировать” нужный ввод.

Проверяем реализацию этой формы в codesandbox.

Передача ref от родителя к дочернему компоненту в качестве пропса

Конечно, в реальных условиях не стоит писать один огромный компонент на все случаи жизни. Лучше извлечь каждый ввод в отдельный компонент и повторно использовать его в разных формах. В этом случае можно инкапсулировать и контролировать собственные стили и даже иметь некоторые дополнительные функции, такие как метка вверху и значок справа.

const  InputField = ({ onChange, label }) => { 
return <>
{label}<br />
<input type="text" onChange={(e) => onChange(e.target.value)} />
< />
}

Но функции обработки ошибок и отправки по-прежнему будут в Form, а не в input!

const  Form = ( ) => { 
const [name, setName] = useState ( '' );

const onSubmitClick = ( ) => {
if (!name) {
// имеем дело с пустым именем
} else {
// отправляем сюда данные!
}
}

return <>
...
<InputField label="name" onChange={setName} />
<button onClick={onSubmitClick}>Отправить форму!</button>
</>
}

Как указать полю ввода «самофокусироваться» из компонента формы (Form)? «Обычное» управление данными и поведением в React  —  передача пропсов компонентам и прослушивание обратных вызовов. Можно попытаться передать пропс «focusItself» к InputField для переключения с false на true. Но это сработает только один раз.

// не делайте этого в реальности! только для демонстрации работы в теории 
const InputField = ( { onChange, focusItself } ) => {
const inputRef = useRef (null);

useEffect ( () => {
if (focusItself) {
// "фокусирование" ввода, если пропс focusItself изменяется,
// сработает только один раз, когда false изменится на true
ref. current . focus ();
}
}, [focusItself])

// остальное здесь то же самое
}

Можно попробовать добавить какой-нибудь обратный вызов «onBlur» и сбросить этот пропс focusItself в false, когда ввод теряет фокус, поэкспериментировать со случайными значениями вместо логических или найти какое-то другое решение.

Вероятно, есть и другой способ. Вместо того чтобы возиться с пропсами, можно просто создать Ref в одном компоненте (Form), передать его другому компоненту (InputField) и прикрепить его к базовому элементу DOM. В конце концов, Ref  —  это всего лишь изменяемый объект.

Затем Form создаст Ref как обычно:

const  Form = ( ) => { 
// создаем компонент Ref в Form
const inputRef = useRef (null);

...
}

А компонент InputField будет иметь пропс, который принимает ref, и поле input, которое принимает ref. Только вместо создания в InputField Ref будет исходить из пропса:

const  InputField = ( { inputRef } ) => { 
// остальная часть кода такая же

// передача ref из пропса во внутренний компонент ввода
return <input ref={inputRef} ... />
}

Ref разработан как изменяемый объект. После передачи элементу, React просто изменяет его. А измененный объект будет объявлен в компоненте Form. Поэтому после рендеринга InputField объект Ref будет изменен, а Form получит доступ к элементу DOM input в inputRef.current:

const  Form = ( ) => { 
// создаем компонент Ref в Form
const inputRef = useRef (null);

useEffect ( () => {
// здесь будет элемент "input", который рендерится внутри InputField
console.log(inputRef.current);
}, []);

return (
<>
{/* Передать ref как пропс компоненту поля ввода */}
<InputField inputRef={inputRef} />
</>
)
}

Или же при отправке обратного вызова можно вызвать inputRef.current.focus()  —  код точно такой же, как и раньше.

Пример здесь.

Передача ref от родителя к дочернему компоненту с помощью forwardRef

Я назвал свойство inputRef, а не просто ref, по следующей причине. ref  —  это не обычный пропс, а своего рода «зарезервированное» имя. Раньше если мы передавали ref компоненту класса при его написании, экземпляр этого компонента был значением .current этого Ref.

Но функциональные компоненты не имеют экземпляров. Об этом дается предупреждение в консоли: «Функциональные компоненты не могут иметь refs. Попытки получить доступ к этому ref будут неудачными. Хотите использовать React.forwardRef()?».

const  Form = ( ) => { 
const inputRef = useRef (null);

// если мы так сделаем, то получим предупреждение в консоли
return <InputField ref={inputRef} />
}

Чтобы это сработало, нужно сигнализировать React о том, что в данный момент мы собираемся использовать этот ref. Сделать это можно с помощью функции forwardRef: она принимает компонент и вставляет ref из атрибута ref в качестве второго аргумента функции компонента, сразу после пропса.

// обычно там только пропсы, 
// но мы обернули функцию компонента с помощью forwardRef,
// которая вводит второй аргумент - ref,
// если он был передан этому компоненту его потребителем
const InputField = forwardRef ( ( props, ref ) => {
// остальной код такой же

return <input ref={ref} />
})

Для лучшей читабельности можно даже разделить приведенный выше код на две переменные:

const  InputFieldWithRef = ( props, ref ) => { 
// остальное то же самое
}

// это будет использоваться формой
export const InputField = forwardRef ( InputFieldWithRef );

Теперь Form может просто передать ref компоненту InputField, как обычному элементу DOM:

return <InputField ref={inputRef} />

Использовать ли forwardRef или просто передавать ref как пропс  —  здесь все зависит от личных предпочтений. Конечный результат будет одинаковый.

Реальный пример в этом CodeSandbox.

Императивный API с useImperativeHandle

Будем считать, что с фокусировкой поля ввода от компонента Form мы разобрались. Но ведь это еще не все. В случае ошибки мы собирались дополнить фокусировку поля ввода вибрацией. Но здесь нет такого элемента, как element.shake() в нативном API Javascript. Так что доступ к элементу DOM тут не поможет.

Эту задачу можно легко реализовать как анимацию CSS:

const  InputField = ( ) => { 
// сохраняем в состоянии, есть ли необходимость во встряхивании
const [shouldShouldShake, setShouldShake] = useState (false);

// просто добавьте имя класса, когда пришло время встряхнуть его — css обработает его
const className = shouldShake ? "shake-animation" : '';

// когда анимация завершена, состояние перехода возвращается в false, чтобы при необходимости можно было начать заново
return <input className={className} onAnimationEnd={() => setShouldShake(false)} />
}

Но как это запустить? Опять та же история, что и раньше с фокусом. Можно придумать какое-то креативное решение с пропсом, но оно выглядело бы странно и сильно усложняло Form. Особенно учитывая обработку фокуса через ref, поэтому используем два решения для одной и той же проблемы. Не помешало бы сделать здесь что-то типа InputField.shake() и InputField.focus()!

Почему для запуска фокусировки компонент Form должен взаимодействовать с нативным API DOM? Разве не для этого нужен InputField, чтобы абстрагироваться от подобных сложностей? Почему Form вообще имеет доступ к базовому элементу DOM  —  это утечка внутренних деталей реализации. Компоненту Form все равно, используем ли мы какой-то элемент DOM или что-то другое. Таков принцип разделения функций.

Теперь реализуем императивный API, соответствующий компоненту InputField. Будучи декларативным инструментом, React ожидает соответствующий код. Но иногда нужен способ принудительно (императивно) запустить что-то. Для этого в React есть хук useImperativeHandle.

Сразу разобраться в этом хуке не так просто. Для этого мне пришлось дважды прочитать документацию, опробовать хук несколько раз и проанализировать реализацию в реальном коде React. Но, по сути, нам нужны всего две вещи: решить, как будет выглядеть императивный API и Ref, к которому его нужно прикрепить. Для нашего ввода это просто: нужны функции .focus() и  .shake() как API, и мы уже знаем все о refs.

// вот как мог бы выглядеть наш API 
const InputFieldAPI = {
focus : () => {
// здесь делаем фокус
},
Shake : () => {
// здесь запускаем Shake
}
}

Хук useImperativeHandle просто прикрепляет этот объект к «текущему» свойству объекта Ref. Вот пример:

const  InputField = ( ) => {

useImperativeHandle (someRef, () => ({
focus : () => {},
Shake : () => {},
}), [])

}

Первый аргумент  —  это Ref, который либо создается в самом компоненте, либо передается из пропса, либо через forwardRef. Второй аргумент  —  функция, возвращающая объект, который будет доступен как inputRef.current. И третий аргумент  —  это массив зависимостей, как и любой другой хук React.

Для нашего компонента явно передадим ссылку как пропс apiRef. И осталось лишь реализовать реальный API. Для этого понадобится еще один ref, на этот раз внутренний для InputField, чтобы можно было прикрепить его к элементу DOM input и активировать фокус, как обычно:

// передаем реф, который будем использовать в качестве императивного API, как пропс 
const InputField = ( {apiRef} ) => {
// создаем другой реф — внутренний для компонента Input
const inputRef = useRef ( null );

// "слить" наш API в apiRef
// возвращенный объект будет доступен для использования как apiRef.current
useImperativeHandle (apiRef, () => ({
focus : () => {
// просто активируем фокус по внутреннему реф, который прикрепляется к объекту DOM
inputRef.current.focus
},
shake: () => {},
}), [])

return <input ref={inputRef} />
}

А для «встряхивания» (shake) просто вызовем обновление состояния:

// передаем Ref, который будем использовать в качестве императивного API, как пропс
const InputField = ( { apiRef } ) => {
// помним наше состояние для встряхивания?
const [shouldShake, setShouldShake] = useState ( false );

useImperativeHandle (apiRef, () => ({
focus : () => {},
Shake : () => {
// здесь запускаем обновление состояния
setShouldShouldShake ( true );
},
}), [])

return ...
}

Теперь Form может просто создать ref, передать его InputField, и можно просто сделать inputRef.current.focus() и inputRef.current.shake(), не заботясь об их внутренней реализации!

const  Form = ( ) => { 
const inputRef = useRef ( null );
const [имя, setName] = useState ( '' );

const onSubmitClick = ( ) => {
if (!name) {
// фокусируем ввод, если пустое поле "имя"
inputRef. текущий . фокус ();
// и встряхнем его!
inputRef.current.shake();
} else {
// отправляем данные сюда!
}
}

return <>
...
<InputField label="name" onChange={setName} apiRef={inputRef} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}

Поэкспериментируйте с примером полной рабочей формы в этом CodeSandbox.

Императивный API без useImperativeHandle

Если хук useImperativeHandle все еще заставляет нервничать, то не волнуйтесь  —  вы в этом не одиноки. К тому же для реализации нашей функциональности можно обойтись и без него. Мы уже знаем, как работают рефы и то, что они изменяемые. Итак, нам нужно просто связать объект API с ref.current используемого Ref, например так:

const  InputField = ( { apiRef } ) => { 
useEffect ( () => {
apiRef. current = {
focus : () => {},
Shake : () => {},
}
}, [apiRef])
}

В любом случае это почти то же самое, что делает useImperativeHandle. И работать будет точно так же.

На самом деле здесь, возможно, даже лучше подойдет useLayoutEffect. Но пока обойдемся традиционным useEffect.

См. последний пример в этом CodeSandbox.

Заключение

Форма с эффектом вибрации готова, рефы React  —  больше не загадка, а императивное API в React  —  это удобный инструмент.

Но следует помнить: Refs  —  «аварийное решение», а не замена состоянию и обычному потоку данных React с пропсами и обратными вызовами. Используйте их только тогда, когда нет «нормальной» альтернативы. То же самое касается императивного запуска. Скорее всего, вам достаточно будет обычного потока пропсов/обратных вызовов.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Nadia Makarevich: Refs in React: from access to DOM to imperative API

Предыдущая статьяРуководство по модулю Python itertools
Следующая статьяПерехват сетевых запросов из мобильного приложения