javascript

Всё под контролем: сила useRef и forwardRef в React

  • вторник, 19 ноября 2024 г. в 00:00:04
https://habr.com/ru/companies/otus/articles/856624/

Начнем с небольшой истории. Как-то раз я с размахом накинулся на проект — в духе нынешних фреймворков всё было обложено компонентами, декларативный стиль царил, все шло идеально… ну почти. Дошел я, значит, до нужды контролировать DOM-узлы напрямую. И что вы думаете? Прямого доступа нет, React закрыл от меня этот мир, сидит и ухмыляется: мол, мы тут за производительность боремся, зачем тебе что-то трогать руками?

Но мы не из тех, кто сдаётся, верно? React предлагает своё решение — рефы, и именно о них сегодня пойдет речь.

Зачем вообще нужны рефы?

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

  • Внедрить фокус на элемент при загрузке страницы или при каком-то событии.

  • Управлять анимацией, отслеживать её этапы или прерывать по нужде.

  • Получить размеры или позицию DOM-узла для расчётов.

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

useRef: первый шаг к прямому доступу

Хук useRef— это инструмент, который позволяет сохранять ссылки на DOM-элементы или любые другие значения, которые нужно сохранить между рендерами. В отличие от useState, изменение значения, хранимого в useRef, не вызывает повторного рендера компонента.

Когда мы вызываем useRef (например, const inputRef = useRef(null);), React создает объект с единственным свойством current, которое мы можем использовать для хранения значения, как в обычном контейнере. Это свойство current инициализируется значением, переданным в useRef, и остается доступным даже после повторного рендера. Отмечаю, что каждый раз, когда мы обновляем inputRef.current, React не реагирует на это обновление рендерингом.

Рассмотрим пример кода, где useRef используется для установки фокуса на текстовое поле при монтировании компонента.

import React, { useRef, useEffect } from 'react';

const TextInput = () =>; {
  const inputRef = useRef(null); // создаем реф

  useEffect(() =>; {
    inputRef.current.focus(); // фокусируем элемент при монтировании
  }, []);

  return <input placeholder="Введи что-нибудь..." type="text">;
};

Вот что здесь происходит:

const inputRef = useRef(null); создает реф-контейнер, который сохраняется между рендерами. В JSX мы передаем inputRef в атрибут ref у <input>, и React автоматически связывает inputRef.current с этим DOM-элементом.

Когда компонент монтируется, useEffect вызывает inputRef.current.focus(), что устанавливает фокус на поле ввода сразу при загрузке, без лишнего состояния и дополнительных манипуляций.

forwardRef: проброс рефов в дочерние компоненты

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

Функция forwardRef позволяет «обернуть» компонент, чтобы он мог принимать и обрабатывать реф, передаваемый родительским компонентом.

Рассмотрим пример, в котором есть кастомная кнопка FancyButton, и родительский компонент хочет управлять ее фокусом.

import React, { forwardRef, useRef } from 'react';

// Компонент FancyButton использует forwardRef для получения доступа к ref
const FancyButton = forwardRef((props, ref) =>; (
  <button>
    {props.children}
  </button>
));

const ParentComponent = () =>; {
  const buttonRef = useRef(null); // создаем реф для передачи в дочерний компонент

  const handleClick = () =>; {
    // Используем реф для доступа к DOM-узлу кнопки
    if (buttonRef.current) {
      buttonRef.current.focus(); // Устанавливаем фокус на кнопке
      console.log("Фокус на кнопке установлен!");
    }
  };

  return (
    <div>
      Нажми меня
      <button>Фокус на первой кнопке</button>
    </div>
  );
};

export default ParentComponent;

Поясню код:

В FancyButton используем forwardRef, чтобы реф, переданный родителем, мог быть связан с внутренним <button>. Это позволяет FancyButton принимать ref и делиться доступом к DOM-узлу на уровне родителя.

В ParentComponent создаем buttonRef через useRef, передаем его в FancyButton, и теперь buttonRef.current даёт доступ к кнопке внутри. В handleClick проверяем buttonRef, и, если он ссылается на DOM-узел, устанавливаем на кнопку фокус. Без forwardRef React воспринимал бы ref как обычный пропс, и мы не могли бы управлять DOM напрямую.

Пример на практике

Рассмотрим комплексный пример, в которомзадействуем и useRef, и forwardRef. Создадим компонент с полем ввода, и обернем его во внешний компонент с кнопками для управления фокусом и очистки значения поля.

Будет три компонента:

  1. TextInput — это компонент, представляющий текстовое поле. Он принимает реф, который позволяет внешнему компоненту управлять его состоянием.

  2. TextInputWrapper — обертка вокруг TextInput, использующая forwardRef для проброса рефа.

  3. ParentComponent — родительский компонент, который создает реф и управляет взаимодействием с TextInputWrapper через кнопки.

Код:

import React, { useRef, forwardRef, useImperativeHandle } from 'react';

// Компонент TextInput с управлением через ref
const TextInput = forwardRef((props, ref) =>; {
  const inputRef = useRef(null);

  // Экспортируем методы, которые будут доступны родительским компонентам
  useImperativeHandle(ref, () =>; ({
    focus: () =>; {
      inputRef.current.focus();
    },
    clear: () =>; {
      inputRef.current.value = '';
    }
  }));

  return <input style="{{" placeholder="Введите текст..." type="text">;
});

// Компонент-обертка TextInputWrapper, который пробрасывает реф вниз
const TextInputWrapper = forwardRef((props, ref) =>; {
  return (
    <div style="{{">
      
    </div>
  );
});

// Родительский компонент, управляющий состоянием TextInput
const ParentComponent = () =>; {
  const inputRef = useRef(null);

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

  const handleClear = () =>; {
    if (inputRef.current) {
      inputRef.current.clear();
    }
  };

  return (
    <div>
      <h1>Демонстрация использования useRef и forwardRef</h1>
      
      <button style="{{">
        Фокус на поле
      </button>
      <button style="{{">
        Очистить поле
      </button>
    </div>
  );
};

export default ParentComponent;

TextInput — это простое поле ввода, в котором мы через useRef создаем реф inputRef для доступа к самому <input>. С помощью useImperativeHandle добавляем два метода — focus и clear, чтобы родительский компонент мог фокусироваться на поле или очищать его, не заглядывая внутрь. Пробрасываем этот реф с помощью forwardRef, чтобы дать родителю полный контроль.

TextInputWrapper и ParentComponent: TextInputWrapper — это обертка для TextInput, показывающая, как пробросить реф еще дальше. В ParentComponent мы создаем реф inputRef и передаем его через обертку. Кнопки "Фокус" и "Очистить" используют этот реф, чтобы управлять TextInput, не вмешиваясь в его внутренности — все происходит через один общий реф, гибко и чисто.


Заключение

Запомните: рефы — это оружие с тонким лезвием. Пользуйтесь ими аккуратно, с умом, и только когда это действительно оправдано.

Всем разработчикам, использующим React, рекомендую к посещению открытый урок 26 ноября — урок пройдет на тему применения хуков и мемоизации для оптимизации React-приложений. Записаться можно на странице курса "React.js Developer".