Использование ref в функциональных компонентах играет две роли:

  1. С помощью них можно получить ссылку на dom элементы и react компоненты

  2. ref можно использовать как стабильные переменные.

В этой статье сосредоточимся на первой роли, разберем, как с помощью ref получить доступ к dom элементам и компонентам react, включая такие какие способы как createRef, useRef и ref callback. Обсудим для чего нужны forwardRef и useImperativeHandle , и как с их помощью получить ссылку на функциональные компоненты, спойлер: нельзя так просто получить ссылку на функциональный компонент с помощью ref. А уже в следующей статье обсудим роль ref в качестве стабильной переменной, и как это облегчит нам жизнь при использовании useEffect, useMemo, useCallback.

Что такое ref и зачем они нужны?

ref или по-другому reference - это ссылка, и как можно понять из названия в контексте react - это ссылка на элемент.

Допустим, есть div с текстом, и нужно получить ссылку на него, чтобы узнать его ширину, как мы можем это сделать? В классическом javascript мы можем использовать document.getElementById('id') или document.querySelector('div').

Классический подход

// html
<div id="custom-id">Элемент с текстом</div>
<script>
const div = document.getElementById('id');
console.log(div.offsetWidth); // получаем ширину элемента
</script>

В классическом подходе мы, скорее всего, не будем добавлять/удалять элементы dom дерева, в react же постоянно это происходит, и если мы попробуем использовать getElementById/querySelector можем столкнуться с ситуацией, когда элемента просто нет в dom дереве.

Посмотрите на код ниже, мы пытаемся получить ссылку на dom элемент классическим способом, но этом не работает, потому что целевой div еще не отрендерен. На момент выполнения getElementById его еще нет в dom.

Разработка на react. Не рабочий вариант

class ClassComponent extends Component {
  render() {
    const div = document.getElementById('id');
    // получим undefined, т.к. целевой div еще не отрендерен
    console.log(div?.offsetWidth);
    
  	return <div id="id">Элемент с текстом</div>
  }
}

const FuncComponent = () => {
  const div = document.getElementById('id');
  // получим undefined, т.к. целевой div еще не отрендерен
  console.log(div?.offsetWidth);
  
  return <div id="id">Элемент с текстом</div>
}

Чтобы получить ссылку на элемент, нужно использовать document.getElementById('id') после рендеринга. В классовом компоненте это componentDidMount, в функциональном компоненте useEffect.

Разработка на react. Рабочий вариант без ref

class ClassComponent extends Component {
  componentDidMount() {
    const div = document.getElementById('id');
    console.log(div.offsetWidth); // получаем ширину элемента
  }
  
  render() {
  	return <div id="id">Элемент с текстом</div>
  }
}

const FuncComponent = () => {
	useEffect(() => {
		const div = document.getElementById('id');
		console.log(div.offsetWidth); // получаем ширину элемента
	}, []);
  
  return <div id="id">Элемент с текстом</div>
}

Да, это рабочий вариант, но нужно указывать id или другие метки, чтобы однозначно получать нужные элементы, а в большом приложении количество элементов с id накапливается и попробуй уследи, чтобы у всех компонентов во всем приложении были разные id. Потому команда react пошла по другому пути и создала ref api.

Как получить ссылку на dom элемент с помощью ref api

В react у каждого элемента есть новый специальный атрибут ref. Ниже приведен рабочий код с использованием ref. Важно понимать, получить ссылку на элемент можно только после рендеринга этого элемента, именно поэтому нужно использовать componentDidMount/useEffect

Разработка на react. Рабочий вариант с использованием ref api

class ClassComponent extends Component {
  element = { current: null };
  
  componentDidMount() {
    // после рендеринга в this.element.current попадет ссылка на элемент
    console.log(this.element.current.offsetWidth);
  }
  
  render() {
  	return <div ref={this.element}>Элемент с текстом</div>
  }
}

const FuncComponent = () => {
  const element = useRef<HTMLDivElement>();
  
  useEffect(() => {
    // после рендеринга в element.current попадет ссылка на элемент
    console.log(element.current.offsetWidth);
  }, []);
  
  return <div ref={element}>Элемент с текстом</div>
}

Обратите внимание на переменную element. В обоих случаях это объект со свойством current и это просто соглашение.

Типизация useRef выглядит вот так

// Вспомогальный тип 
interface MutableRefObject<T> {
  current: T;
}

function useRef<T>(initialValue?: T): MutableRefObject<T>

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

Функция createRef

Есть специальная функция createRef, она возвращает { current: null } и по сути является синтаксическим сахаром.

class ClassComponent extends Component {
  // эти две записи аналогичны
  element = { current: null };
  element = createRef();
  
  componentDidMount() {
    // после рендеринга в this.element.current попадет ссылка на элемент
    console.log(this.element.current.offsetWidth);
  }
  
  render() {
  	return <div ref={this.element}>Элемент с текстом</div>
  }
}

Типизация createRef

interface RefObject<T> {
  readonly current: T | null;
}

function createRef<T>(): RefObject<T>;

Заметили? useRef возвращает MutableRefObject, то есть объект вида { current: T }, который можно изменять, а вот createRef возвращает объект { readonly current: T }, соответственно не предполагается изменение значения current. Это опять же напоминает нам о том, что useRef можно использовать как стабильную переменную, которую можно изменять, а вот createRef не стоит изменять и использовать эту функцию нужно преимущественно в рамках классовых компонентов.

Как получить ссылку на классовый компонент

В react атрибут ref есть не только у html элементов, но и у всех компонентов. С его помощью можно получить внутренние методы и переменные классовых компонентов. Посмотрите на этот код.

В консоли мы на строке 15 получим вот такие данные

То есть получим доступ к внутренним данным компонента: не только к его переменным и методам, что мы указали, но также и к props, setState, forceUpdate и прочему.

Пример кода тут.

LegacyRef и использование строки в качестве ref

В комментариях спросили, что за LegacyRef в примере кода выше (20 строка testRef as LegacyRef). Я не хотел писать про этот способ, ведь он устаревший, что понятно из названия, но, действительно, без этого картина будет незавершенной.

Обратите внимание на внутренности TestClassComponent, что мы видим в консоли на картинке выше, у него есть свойство refs. Также, если посмотрим, что из себя представляет LegacyRef, то увидим следующую картину:

type Ref<T> = RefCallback<T> | RefObject<T> | null;
type LegacyRef<T> = string | Ref<T>;

В устаревшем использовании можно было в качестве ref атрибута указать строку, (внимание, это работает строго в классовых компонентах), например так:

И если теперь мы взглянем, что из себя представляет TestClassComponent, то обнаружим, что в его свойстве refs под именем rootDivRef находится ссылка на наш div элемент.

Но повторюсь, этот способ - устаревший, и в будущих версиях react он будет удален, а также в консоли вы увидите предупреждение.

Ref callback

Также в атрибут ref можно передавать не только объект вида { current: T }, но и функцию, которая единственным аргументом принимает ссылку на элемент, этот прием называется ref callback:

class ClassComponent extends Component {
  element = null;
  
  componentDidMount() {
    // после рендеринга в this.element попадет ссылка на элемент
    console.log(this.element.offsetWidth);
  }
  
  render() {
  	return <div ref={elem => (this.element = elem)}>Элемент с текстом</div>
  }
}

Ref callback и получение массива ссылок

Может возникнуть вопрос, зачем вообще нужен такой способ, как ref callback? Ref callback позволяет получать массив ссылок. На практике был следующий случай: есть массив элементов списка произвольной длины, этот список участвует в drag-and-drop сортировке и для работы программы нужно знать координаты расположения элементов на странице, но для начала нужно получить ссылки на элементы, в чем и помогает ref callback api. Однако есть нюансы, ниже об этом и поговорим.

Взгляните на этот пример.

В строке 8 мы используем useReducer, это аналог useState, то есть он хранит состояние компонента, мы еще поговорим про этот хук в будущих лекциях. Пока все что нужно знать - здесь он обрезает переменную list при нажатии на кнопку "обрезать список", а на строке 12 используем useReducer, который создан для принудительного обновления компонента при клике по кнопке "обновить компонент".

Начиная с 14 строки по 18 создаю ref, как стабильные переменные. Все они - массивы, которые могут содержать либо HTMLDivElement либо null. И может возникнуть вопрос, при чем тут null? Это не совсем очевидно и об этом ниже, а пока взгляните на строку 33, мы в нашу первую стабильную переменную listRef1, т.к. это массив, добавляем с помощью метода push ссылки на div элементы. И так как наш список элементов и переменная listRef1 находятся в одном компоненте, обновляться они будут одновременно, потому мы совершенно легально в строке 15 сбрасываем listRef1.

Важно понимать, при обновлении компонента все ref callback сработают повторно, поэтому, если не сбросить listRef1 - из-за метода push переменная listRef1 будет постоянно увеличиваться. Но есть еще не вполне очевидный момент.

Как считаете, сколько раз будет вызван ref callback при одном обновлении компонента? Оказывается при обновлении (не монтировании) компонента ref callback вызывается дважды: первый раз elem аргумент будет null, а второй раз elem аргумент уже будет ссылкой на элемент. Попробуйте в примере кода обновлять компонент, listRef1 будет выглядеть так:

Именно поэтому мы указали useRef<HTMLDivElement | null>. Почему поведение именно такое, станет понятно при работе с другими listRef.

listRef2 и listRef3 аналогичным образом записывают в себя элементы - по индексу (см строки 40 и 47). Единственное отличие в том, что listRef2 сбрасывается аналогично listRef1 при каждом обновлении. И в консоли при обновлении компонента они выглядят лучше чем listRef1 - это всегда целевые 20 элементов.

Но, что если при обновлении список элементов уменьшается? Это можно проверить, нажимая кнопку "обрезать список" и в консоли увидим следующее.

И здесь становится очевидно, что null прилетает аргументом в ref callback при "условном" размонтировании. Я говорю "условном", потому что при обновлении компонентов, не происходит реального размонтирования, тем не менее в ref callback также прилетает null, видимо под капотом срабатывает некоторая фаза "перед обновлением", но нам на практике это не важно, важно другое: при размонтировании точно прилетит null и это поведение идеально вписывается в наш способ получения массива ссылок.

Когда мы, например, добавили в массив какой-то объект, а затем этот объект удалили отовсюду, только массив не трогали, сборщик мусора не сможет удалить этот объект из памяти. Ссылка (ref) - это тоже объект, и когда мы размонтировалии компонент, он будет храниться в нашем массиве ссылок и, соответственно, в памяти.

Соответсвенно есть два пути:

  1. Пересоздавать массив ссылок при обновлении, как это делается в строке 17. Так у нас будет из памяти удален массив со всем его содержимым.

  2. Записывать null вместо размонтированного объекта, в чем нам очень помогает ref callback с аргументом null

Главная задача ref callback - это создание массива ссылок на dom элементы и react компоненты.

Ref с функциональными компонентами работает по-другому

При попытке получить ссылку на функциональный компонент мы получим другой результат. Уже при попытке указать ref для функционального компонента получим ошибку typescript.

А в консоли увидим следующее предупреждение и получим undefined вместо данных компонента.

Как получить ссылку на html элемент внутри функционального компонента. Использование forwardRef

Как следует из названия forwardRef - "перенаправить ref", это и есть суть этой функции. forwardRef - это функция, которая принимает функциональный компонент и возвращает новый функциональный компонент, который может иметь атрибут ref.

Вот так выглядит типизация

interface ForwardRefRenderFunction<T, P = {}> {
  (props: PropsWithChildren<P>, ref: ForwardedRef<T>): ReactElement | null;
  displayName?: string;
  // explicit rejected with `never` required due to
  // https://github.com/microsoft/TypeScript/issues/36826
  /**
   * defaultProps are not supported on render functions
   */
  defaultProps?: never;
  /**
   * propTypes are not supported on render functions
   */
  propTypes?: never;
}

interface ForwardRefExoticComponent<P> extends NamedExoticComponent<P> {
  defaultProps?: Partial<P>;
  propTypes?: WeakValidationMap<P>;
}

function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

Она может показаться страшной, на практике же, изменяется всего две вещи:

  1. forwardRef это дженерик и ему нужно передать 2 типа: тип ref и тип props, подробнее ниже.

  2. У функционального компонента появляется второй аргумент - ref.

В общем виде компонент с использованием forwardRef выглядит так.

const FuncComponent = forwardRef<RefType, PropsType>((props: PropsType, ref: RefType) => {
  ...
})

Посмотрите на этот код

Здесь реализовано перенаправление ссылки на div элемент. Посмотрите на forwardRef<HTMLDivElement>, первым типом мы передали HTMLDivElement, это значит, что ref в нашем случае будет именно div-ом. И обратите внимание, наш TestFuncBase компонент вторым аргументом, получает ref аргумент (3 строка) и мы передаем его в ref атрибут div-а (4 строка). Поэкспериментировать с примером можно тут.

Как получить ссылку на функциональный компонент. forwardRef + useImperativeHandle

Мы узнали, как перенаправить на html элемент ref, который используем для функционального компонента, но это все еще не дотягивает до случая с классовым компонентом, когда мы может из компонента подтянуть состояние, изменить его состояние, получить внутренние переменные и методы. Можно ли воссоздать эти возможности при работе с функциональными компонентами? Да, можно, но не в полной мере или скорее по-другому, в ref попадет ровно то, что мы укажем.

Существует специальный хук useImperativeHandle, миссия которого записывать в ref любые данные изнутри функционального компонента.

Его типизация выглядит так:

function useImperativeHandle<T, R extends T>(ref: Ref<T> | undefined, init: () => R, deps?: any[]): void;

Это дженерик, первым аргументом принимает тип ref, который должен полностью совпадать в типом ref, что прилетел вторым аргументом функционального компонента. Второй тип - полностью совпадает с типом ref, либо может содержать дополнительные свойства.

Если говорить об этом хуке, как о функции, то первым аргументом он принимает ref, который прилетел в функциональный компонент извне. Вторым аргументом принимает функцию (init), которая возвращает объект, что будет записан в ref. А третий аргумент (deps) - это массив зависимостей.

Работа в связке с forwardRef может выглядеть так:

В forwardRef (15 строка) передаем тип TestFuncAdvancedRef - это объект, внутри которого есть свойства someVars, state, setState (9 строка) и теперь второй аргумент нашего функционального компонента (props, ref на 15 строке) должен получить все эти свойства, пока он { current: null }. В строке 19 мы вызываем useImpertiveHandle и передаем ему дважды один и тот же тип TestFuncAdvancedRef первый тип ответственен за ref, что мы передаем первым аргументом (19 строка), второй тип ответственен на то, что вернет второй аргумент, а именно функция init, в нашем случае начиная с 19 по 23 строку она возвращает объект, что по структуре соответсвует типу TestFuncAdvancedRef .

Поиграть с примером можно тут. Обратите внимание, не использую массив зависимостей, об этом ниже.

Магия типизации useImperativeHandle

Код выше выглядит громоздко. Главная магия заключается в том, что мы могли бы не передавать никаких типов в useImpertiveHandle вообще. Когда в useImpertiveHandle передаем ref, он автоматически прочитывает его тип и по умолчанию считает, что функция init должна вернуть этот же тип.

А указание двух типов нужно только если мы хотим, чтобы init добавлял дополнительные приватные свойства. Типизация с двумя типами показана, как академический пример.

Массив зависимостей в useImperativeHandle

Функция init, как и в остальных хуках, клонируется и в ней присутствует проблема потери замыкания, о которой мы говорили в прошлой статье. И у нас есть два пути.

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

type FuncForwardRefComponentRef = {
  onClick: () => void;
}

const FuncForwardRefComponent = forwardRef<FuncForwardRefComponentRef>((props, ref) => {
   /* при любом обновлении компонента в ref.onClick записывается новая функция
  (ссылка на новую ячейку памяти, потому это новая функция) */
  useImperativeHandle(ref, () => ({ onClick: () => { ... } }));
  
  ...
})

const FuncComponent = ({ triggerClick }) => {
  const ref = useRef<FuncForwardRefComponentRef>();

  // при изменении triggerClick будет вызываться. 
  // Но не нужно ref.current.onClick указывать в массиве зависимостей,
  // потому нам не обязательно, чтобы onClick всегда был одной и той же ссылкой
  useEffect(() => {
    ref.current.onClick();
  }, [triggerClick])

  // аналогично и тут не нужно указывать в массиве зависимостей
  const onClick = useCallback(() => ref.current.onClick(), [])
  
  return (
    <div>
      <FuncForwardRefComponent ref={ref} />
    </div>
  )
}

Второй способ - можно указать в массиве зависимостей все переменные, которые могут потеряться, впрочем eslint плагин для хуков подскажет, что нужно добавить.

И честно сказать, я не знаю точно, когда может понадобится массив зависимостей. Буду рад подсказкам в комментариях.

Раньше мне казалось, что единственный случай, когда нужен массив зависимостей, когда из функционального компонента получаем методы (ссылочный тип) и эти методы передаем сразу в другой мемоизированный компонент.

// ВНИМАНИЕ - это не рабочий код
type FuncForwardRefComponentRef = {
  onClick: () => void;
}

const FuncForwardRefComponent = forwardRef<FuncForwardRefComponentRef>((props, ref) => {
   /* в этом случае массив зависимостей необходим, 
  без него будет при обновлении компонента каждый раз создаваться новый onClick,
  который в свою очередь заставит обновляться MemoButton */
  useImperativeHandle(ref, () => ({ onClick: () => { ... } }), []);
  
  ...
})

const FuncComponent = () => {
  const ref = useRef<FuncForwardRefComponentRef>();
  
  return (
    <div>
      <FuncForwardRefComponent ref={ref} />
      <MemoButton onClick={ref.current.onClick}>Мемоизированная кнопка</MemoButton>
    </div>
  )
}

Однако в этом примере MemoButton получит undefined в качестве onClick. Это происходит потому, что на момент рендеринга MemoButton в ref все еще { current: undefined } и решить это можно так, но в этом случае по-прежнему массив зависимостей для useImperativeHandle не нужен.

type FuncForwardRefComponentRef = {
  onClick: () => void;
}

const FuncForwardRefComponent = forwardRef<FuncForwardRefComponentRef>((props, ref) => {
   /* в этом случае массив зависимостей необходим, 
  без него будет при обновлении компонента каждый раз создаваться новый onClick,
  который в свою очередь заставит обновляться MemoButton */
  useImperativeHandle(ref, () => ({ onClick: () => { ... } }), []);
  
  ...
})

const FuncComponent = () => {
  const ref = useRef<FuncForwardRefComponentRef>();

  const onClick = useCallback(() => ref.current.onClick(), []);
  
  return (
    <div>
      <FuncForwardRefComponent ref={ref} />
      <MemoButton onClick={onClick}>Мемоизированная кнопка</MemoButton>
    </div>
  )
}

Заключение

ref или reference, то есть ссылки позволяют получить доступ к dom элементам, а также к react компонентам. Доступ к dom элементам и классовым компонентам можно получить без проблем, достаточно передать в ref атрибут объект типа { current: null } и в свойство current будет записана ссылка на элемент. Или можно передать в атрибут функцию, первым аргументом в которую прилетит ссылка на элемент, нам останется только ее сохранить.

При работе с функциональными компонентами используем те же способы работы с атрибутом ref, но потребуются дополнительные шаги. Функциональные компоненты нужно обернуть в forwardRef, так внутри функционального компонента мы получим второй аргумент ref и сможем его передать в нужный html элемент. Либо этот ref нужно передать в useImperativeHandle - это специальный хук, который добавит в ref дополнительные свойства. Это позволит поднимать из функционального компонента данные и методы.

Всех заинтересованных приглашаю на бесплатный урок курса React.js по теме "Создание быстрых сайтов с Astro.build". Узнать подробнее о курсе и зарегистрироваться на бесплатный урок можно по ссылке ниже.

Комментарии (12)


  1. Hellion
    08.11.2022 15:16
    +1

    Было бы неплохо добавить ссылки на другие Ваши статьи из этой серии.


  1. mayorovp
    08.11.2022 16:12

    Выглядит громоздко, я знаю, и я бы чуть изменил типизацию этого хука, чтобы можно было передавать тип все один раз, потому что ref и результат вызова init в 99% случаев должны совпадать. И, честно сказать, не знаю ситуации, когда было бы корректно, чтобы init добавлял какие-то дополнительные свойства в ref. Зачем, если снаружи компонента никто про это не узнает?

    Приватные поля же. Не, я понимаю, можно всё в рефы запихать...


    useImperativeHandle(ref, () => ({
        _foo: 42,
        get foo() {
            return this._foo;
        }
    }), []);


    1. igor_zvyagin Автор
      08.11.2022 16:20

      Да, но, зачем? Ведь при обращению к ним, если пишем на ts, будут ошибки (а без ts нас и не заботят типы). Лучше их сделать публичными, чтобы не было ошибок компиляции, а если они публичные, то есть и ref: T и init: () => T это один и тот же тип, то почему бы не упростить запись до такой

      useImperativeHandle<RefType>(ref, () => ({}}), []);

      Почему нужно требовать указать второй тип, я не понимаю

      useImperativeHandle<RefType, RefType>(ref, () => ({}}), []);


      1. mayorovp
        08.11.2022 16:26

        Потому что Typescript не даст вам вернуть литерал объекта с лишними полями. Иными словами, тут костылём в библиотеке обходят костыль языка.


        1. igor_zvyagin Автор
          08.11.2022 16:39

          Да бросьте, простое улучшение решит проблему

          // было
          function useImperativeHandle<T, R extends T>(ref: Ref<T> | undefined, init: () => R, deps?: any[]);
          // стало
          function useImperativeHandle<T, R extends T = T>(ref: Ref<T> | undefined, init: () => R, deps?: any[]);


          1. mayorovp
            08.11.2022 17:53

            Не пробовали им PR отправить?


            1. igor_zvyagin Автор
              08.11.2022 20:53

              Пока с Вами обсуждал, понял, что в принципе не нужно передавать в этот хук никаких типов, проверка происходит автоматически. Благодарю Вас!


  1. Alexandroppolus
    08.11.2022 20:50
    +2

    В самом последнем примере ошибка. На момент рендера FuncComponent всё ещё не заполнен ref.current, и нельзя обратиться к ref.current.onClick. Функция в useImperativeHandle отрабатывает после рендера, на том же этапе, где и useLayoutEffect

    https://codesandbox.io/s/hidden-dream-lh9tfh (см. консоль; я там написал current?.onClick, чтобы не ломалось).

    Для передачи нормального onClick в MemoButton надо вручную вызвать повторный рендер FuncComponent, например пнуть стейт в useLayoutEffect. И делать это следует каждый раз, когда useImperativeHandle отработал и вернул новый объект. Потому в целом эта идея передавать ref.current в пропсы чилда - не совсем удачная. Можно передавать ref и обращаться к его current только в событиях или useEffect.


    1. igor_zvyagin Автор
      08.11.2022 20:54

      Вы правы, благодарю


    1. igor_zvyagin Автор
      08.11.2022 21:02

      исправил


  1. hazratgs
    09.11.2022 12:09
    +1

    Вы достаточно подробно описали каждый шаг, но в момент добавления LegacyRef<>, про него умолчали, почему нельзя было указать ref без преобразования в LegacyRef


    1. igor_zvyagin Автор
      09.11.2022 22:08
      +1

      Благодарю, хороший вопрос. Внес правки, теперь в лекции написано про LegacyRef. а почему указал as LegacyRef, на самом деле это не обязательно, можно было указать as Ref. Почему-то в react еще есть этот конфликт типов, когда мы используем useRef вместе с классовыми компонентами, почему не знаю, но для классовых компонентов приходится указывать as Ref | LegacyRef