React Callback Refs: Что это такое и как их использовать

Во время разработки нам часто требуется непосредственное взаимодействие с элементами DOM. В таких случаях React предоставляет нам механизм, называемый refs, который позволяет получить доступ к элементам после их отрисовки. Обычно мы используем стандартные объектные refs через useRef (давайте назовем их так), но существует и другой подход, известный как callback refs. Этот метод предлагает дополнительную гибкость и контроль над жизненным циклом элементов, позволяя выполнять определенные действия в точных моментах, когда элементы присоединяются или отсоединяются от DOM.

В этой статье я хочу объяснить, что такое callback refs и как они работают, обсудить возможные проблемы, с которыми вы можете столкнуться, и показать примеры их использования.

Что такое Callback Refs и как они работают?

Callback refs дают вам более детальный контроль над присоединением ref по сравнению с объектными refs. Давайте посмотрим, как они работают на практике:

  1. Присоединение. Когда элемент присоединяется к DOM, React вызывает функцию ref с самим DOM-элементом. Это позволяет вам выполнять действия с элементом сразу после его появления на странице.
  2. Отсоединение. Когда элемент отсоединяется, React вызывает функцию ref с null. Это дает вам возможность выполнить очистку или отменить любые действия, связанные с этим элементом.

Пример: Отслеживание Присоединения и Отсоединения

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

Каждый раз, когда мы переключаем видимость элемента, вызывается функция handleRef с узлом или null, что позволяет нам отслеживать момент прикрепления или открепления элемента.

Общие проблемы и решения

Проблема: Повторные вызовы ссылки обратного вызова

Частая проблема при использовании ссылок обратного вызова – повторное создание функции ссылки при каждом повторном рендеринге компонента. Из-за этого React считает, что это новая ссылка, вызывает старую с null (очищая ее) и затем инициализирует новую — даже если наш элемент или компонент на самом деле не изменился. Это может привести к нежелательным побочным эффектам.

Пример проблемы

Предположим, что у нас есть компонент Basic, который имеет кнопку для переключения видимости div с обратной ссылкой, а также другую кнопку для принудительного повторного рендеринга компонента:

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Каждый раз, когда вы нажимаете на кнопку Перерендер, компонент повторно рендерится, создавая новую функцию refCallback. В результате React вызывает старый refCallback(null), а затем новый refCallback(node), даже если наш элемент с ссылкой не изменился. В консоли вы увидите div null, а затем div [node] поочередно, многократно. Очевидно, что мы обычно хотим избежать таких ненужных вызовов.

Решение: Мемоизация ссылки обратного вызова с помощью useCallback

Избежать этого довольно просто: просто используйте useCallback для мемоизации функции. Таким образом, функция остается неизменной при повторных рендерах, если только ее зависимости не изменятся.

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

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

Порядок вызовов Refs, useLayoutEffect и useEffect

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

Порядок выполнения

  1. callback ref – вызывается непосредственно после рендеринга DOM-элементов, до выполнения эффектов
  2. useLayoutEffect – выполняется после всех мутаций DOM, но до того, как браузер отобразит изменения
  3. useEffect – выполняется после того, как компонент завершит рендеринг на экране
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

Вывод в консоль

  1. Callback ref called for div: [div element]
  2. useLayoutEffect called
  3. useEffect called

Эта последовательность говорит нам, что callback refs вызываются перед хуками, такими как useLayoutEffect и useEffect, что важно помнить при написании логики.

Какие проблемы решают callback refs в коде?

Давайте сначала воспроизведем проблему, с которой обычно сталкиваются с обычными объектными refs, чтобы затем решить ее с помощью callback refs.

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

Мы не будем вдаваться в каждую деталь здесь. Кратко говоря, мы отслеживаем размер нашего элемента div или p с помощью ResizeObserver. Изначально всё работает нормально: при монтировании мы можем получить размер элемента, и изменения размеров также отображаются в консоли.

Настоящие проблемы начинаются, когда мы переключаем состояние для смены элемента, который мы наблюдаем. Когда мы меняем состояние и, таким образом, заменяем отслеживаемый элемент, наш ResizeObserver больше не работает правильно. Он продолжает наблюдать за первым элементом, который уже удален из DOM! Даже возврат к исходному элементу не помогает, потому что подписка на новый элемент никогда не прикрепляется должным образом.

Примечание: Следующее решение более представительно для того, что вы могли бы написать в контексте библиотеки, требующей универсального кода. В реальном проекте вы могли бы решить это через комбинацию флагов, эффектов и т. д. Но в коде библиотеки у вас нет знаний о конкретном компоненте или его состоянии. Это именно тот сценарий, в котором нам могут помочь коллбэк-ссылки.

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

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

Основное преимущество здесь заключается в том, что разработчику, использующему наш хук, больше не нужно беспокоиться о логике добавления/удаления наблюдателей под капотом — мы инкапсулировали эту логику внутри хука. Разработчику просто нужно передать обратный вызов в наш хук и присоединить его возвращенную ссылку к своим элементам.

Комбинирование нескольких ссылок в одну

Вот еще один сценарий, когда обратные вызовы по ссылкам приходят на выручку:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

В приведенном выше коде у нас есть простой компонент ввода, на котором мы устанавливаем ссылку, получая ее из свойств с помощью forwardRef. Но как использовать inputRef внутри компонента Input, если нам также нужна ссылка снаружи для чего-то вроде фокусировки? Возможно, мы хотим сделать что-то еще в самом компоненте ввода, например, getBoundingClientRect. Замена ссылки свойства на нашу внутреннюю ссылку означает, что фокусировка снаружи больше не будет работать. Так как же объединить эти две ссылки?

И вновь на помощь приходят обратные вызовы по ссылкам:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

Объяснение

Мы реализовали хук useCombinedRef, в котором разработчик может передать стандартные ссылки, обратные вызовы по ссылкам, и, по желанию, null или undefined. Сам хук представляет собой просто useCallback, который перебирает массив ссылок. Если аргумент равен null, мы его игнорируем, но если ссылка является функцией, мы вызываем ее с элементом; если это стандартный объект ссылки, мы устанавливаем ref.current в элемент.

Таким образом, мы объединяем несколько ссылок в одну. В приведенном выше примере как getBoundingClientRect внутри компонента Input, так и внешний вызов фокуса будут работать правильно.

Что изменилось в React 19 относительно обратных ссылок через колбэк?

Автоматическая очистка

Теперь React автоматически обрабатывает очистку обратных ссылок через колбэк при размонтировании элементов, что упрощает управление ресурсами. Вот пример, который показала команда React в своей документации:

TypeScript

 

 {
    // создание ссылки

    // НОВОЕ: возврат функции очистки для сброса
    // ссылки при удалении элемента из DOM
    return () => {
      // очистка ссылки
    };
  }}
/>

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

Выбор между обычными ссылками и ссылками через колбэк

  • Используйте стандартные ссылки (useRef), когда вам просто нужен простой доступ к DOM-элементу или вы хотите сохранить какое-то значение между рендерами без дополнительных действий при присоединении или отсоединении.
  • Используйте ссылки через колбэк, когда вам требуется более детальное управление жизненным циклом элемента, когда вы пишете универсальный код (например, в своей собственной библиотеке или пакете) или когда вам нужно управлять несколькими ссылками вместе.

Заключение

Callback-ссылки в React – это мощный инструмент, который дает разработчикам дополнительную гибкость и контроль при работе с элементами DOM. В большинстве случаев стандартные объектные ссылки через useRef достаточны для повседневных задач, но callback-ссылки могут помочь в более сложных сценариях, подобных описанным выше.

Source:
https://dzone.com/articles/react-callback-refs-guide