Урок 17: Хук useRef (доступ к DOM и хранение мутируемых значений)

Мы уже изучили множество концепций, но сегодня нас ждет особенный инструмент, хук useRef. Он не такой «громкий», как useState или useEffect, но в умелых руках творит настоящие чудеса.

Готовься узнать, как напрямую «общаться» с DOM-элементами и хранить данные между рендерами, не вызывая при этом повторный рендер.

Зачем нужен useRef ?

Представь себе ситуацию: тебе нужно программно установить фокус на поле ввода, проскроллить страницу до определенного элемента или измерить его размеры. Как это сделать в React? С помощью пропсов и состояния? Не всегда удобно, а иногда и вовсе невозможно. Именно для таких сценариев и был создан хук useRef.

По своей сути, useRef это функция, которая возвращает нам мутируемый (изменяемый) JavaScript-объект, у которого есть одно свойство current. В этот current мы можем записать что угодно: ссылку на DOM-узел, значение примитивного типа, массив, объект, даже функцию. Самое главное, что этот объект сохраняется между рендерами нашего компонента. В отличие от состояния, созданного через useState, обновление ref не заставляет компонент перерисовываться. Он живет своей жизнью, тихо и спокойно, в памяти.

Можно провести такую аналогию: если состояние (state) это официальная, публичная память компонента, которая при изменении вызывает его «перерождение» (рендер), то ref это его личный, секретный блокнот. Компонент может в него заглянуть, что-то записать, стереть и никто об этом не узнает, потому что внешне (в интерфейсе) ничего не изменится. Мы будем рассматривать два основных применения этого «секретного блокнота»: прямой доступ к DOM-элементам и хранение мутируемых значений, не влияющих на рендер.

Прямой доступ к DOM-элементам

Это, пожалуй, самая частая и очевидная причина для использования useRef. React использует виртуальный DOM и декларативный подход: мы описываем, как интерфейс должен выглядеть в зависимости от состояния. Мы не командуем браузеру: «Эй, найди мне этот input и дай ему фокус!». Вместо этого мы говорим: «Интерфейс должен отображать поле ввода и если значение isFocused равно true, то этот input должен быть в фокусе».

Но иногда «императивный» подход, прямой приказ DOM-элементу, проще и эффективнее. Например, для управления фокусом, выделения текста или интеграции со сторонними библиотеками (например, для графиков или карт), которым нужно передать непосредственно DOM-узел.

Как это работает?

  1. Мы создаем ref-объект с помощью хука useRef().

  2. Мы «прикрепляем» этот объект к JSX-элементу с помощью специального атрибута ref.

  3. После того как компонент отрендерился и React создал реальный DOM-узел, он автоматически запишет ссылку на этот узел в свойство ref.current.

Теперь у нас в руках прямая ссылка на живой DOM-элемент и мы можем делать с ним все, что разрешает браузерный JavaScript.

Практический пример: фокус на поле ввода

Давай создадим компонент, который при нажатии на кнопку автоматически переводит фокус на поле ввода. Это классическая задача.

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

function FocusInput() {
  // 1. Создаем ref-объект. Изначально его current равен null.
  const inputRef = useRef(null);

  const handleClick = () => {
    // 3. В обработчике получаем доступ к DOM-элементу через inputRef.current
    const inputElement = inputRef.current;

    // Проверяем, что элемент существует (на всякий случай)
    if (inputElement) {
      // Императивная команда браузеру: установить фокус на input!
      inputElement.focus();

      // Можем также сделать что-то еще, например, выделить текст
      // inputElement.select();
    }
  };

  return (
    <div>
      {/* 2. "Прикрепляем" наш inputRef к тегу input с помощью атрибута ref */}
      <input
        ref={inputRef}
        type="text"
        placeholder="Нажми на кнопку, чтобы я получил фокус!"
      />
      <button onClick={handleClick}>Установить фокус на поле</button>
    </div>
  );
}

export default FocusInput;

Давай разберем по шагам, что здесь происходит:

  1. const inputRef = useRef(null);. Внутри компонента мы создаем наш «секретный блокнот» ref-объект. Мы инициализируем его значением null, потому что изначально, до рендера, DOM-элемента еще не существует.

  2. <input ref={inputRef} ... />. В JSX мы передаем этот объект в атрибут ref тега input. Это указание для React: «Как только этот элемент будет создан в реальном DOM, положи ссылку на него в свойство inputRef.current».

  3. inputRef.current.focus();. Когда пользователь нажимает на кнопку, вызывается handleClick. Внутри него мы обращаемся к inputRef.current и вот он, наш настоящий DOM-элемент input! Дальше мы просто вызываем его нативный метод .focus().

Обрати внимание. Мы не используем здесь никакое состояние. Компонент не перерендеривается когда ref обновляется. Все происходит мгновенно и «за кадром».

Хранение мутируемых значений между рендерами

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

«Погоди, скажешь ты, но для хранения данных у нас же есть useState и переменные внутри компонента!» Да, но у них есть нюансы.

  • useState. При каждом изменении состояния компонент перерисовывается. Если мы храним в состоянии значение, которое используется только в логике (например, ID таймера, индекс массива для цикла, кешированные данные), то его обновление будет вызывать лишний и дорогой рендер.

  • Переменные внутри компонента. Они инициализируются заново при каждом рендере! У них нет постоянства.

А вот useRef дает нам «коробку», в которой данные сохраняются на протяжении всей жизни компонента и при этом обновление содержимого этой коробки не трогает рендеры.

Практический пример: счетчик рендеров и хранение предыдущего значения

Давай создадим компонент, который показывает текущее значение из useState и подсвечивает, изменилось ли оно по сравнению с предыдущим рендером. А заодно мы посчитаем, сколько раз компонент уже отрендерился.

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

function CounterWithHistory() {
  const [count, setCount] = useState(0);
  // 1. Ref для хранения счетчика рендеров
  const renderCount = useRef(0);
  // 2. Ref для хранения предыдущего значения count
  const prevCount = useRef(null);

  // useEffect, который срабатывает после каждого рендера
  useEffect(() => {
    // 3. Увеличиваем счетчик рендеров. Это НЕ вызовет новый рендер!
    renderCount.current = renderCount.current + 1;
    console.log(`Компонент отрендерился ${renderCount.current} раз`);
  }); // Нет массива зависимостей - срабатывает после каждого рендера

  // Другой useEffect для отслеживания предыдущего значения
  useEffect(() => {
    // 4. Обновляем ref с предыдущим значением
    prevCount.current = count;
  }, [count]); // Сработает только когда изменится count

  const increment = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <h2>Текущий счет: {count}</h2>
      {/* 5. Отображаем предыдущее значение из ref */}
      <p>Предыдущий счет: {prevCount.current ?? 'еще не было'}</p>
      {/* 6. Отображаем количество рендеров */}
      <p>Компонент обновлялся: {renderCount.current} раз</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

export default CounterWithHistory;

Давай проанализируем логику:

  1. const renderCount = useRef(0);. Мы создаем ref для хранения количества рендеров. Начальное значение 0.

  2. const prevCount = useRef(null);. Создаем еще один ref для хранения предыдущего значения count.

  3. В useEffect без зависимостей (который запускается после каждого рендера) мы обновляем renderCount.current. Ключевой момент: это обновление ref не вызовет новый рендер, иначе мы бы попали в бесконечный цикл (рендер -> useEffect -> обновление ref -> рендер…). Этого не происходит! Счетчик тико обновляется в памяти.

  4. Во втором useEffect, который зависит от [count], мы записываем текущее значение count в prevCount.current. Обрати внимание: в момент этого useEffect значение prevCount.current все еще содержит значение count с предыдущего рендера. Потому что этот useEffect запускается после того, как компонент уже отобразил актуальное count.

  5. В JSX мы отображаем prevCount.current. Когда count меняется, компонент рендерится заново и в этот момент prevCount.current уже содержит старое значение, которое мы и показываем.

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

useRef или useState: когда что использовать?

Давай четко разграничим эти два инструмента, чтобы у тебя не было каши в голове.

Признак useState useRef
Триггерит рендер Да. При обновлении значения компонент перерисовывается. Нет. Обновление .current происходит без перерисовки.
Изменяемость Иммутабельное обновление через функцию-сеттер (setValue(newValue)). Мутабельное обновление через прямое присвоение (ref.current = newValue).
Доступ к значению Через саму переменную состояния (value). Через свойство .current (ref.current).
Использование Для хранения данных, которые напрямую влияют на то, что видит пользователь (UI). 1. Прямой доступ к DOM.
2. Хранение значений, которые не должны влиять на UI (таймеры, инстансы классов, кеш).
Чтение во время рендера Можно и нужно. Можно, но не следует изменять. Изменение ref во время рендера может сделать компонент непредсказуемым. Меняйте ref только в обработчиках событий или эффектах.

Если данные должны отображаться в JSX или их изменение должно немедленно привести к обновлению интерфейса используй useState. Во всех остальных случаях, когда тебе нужно просто «запомнить» что-то между рендерами, не трогая UI, смело бери useRef.

Важные предупреждения и лучшие практики

Работа с useRef дает большую силу, но и требует ответственности.

  1. Не изменяй ref.current во время рендера. Рендер должен быть «чистой» функцией, он не должен иметь побочных эффектов. Если ты будешь менять ref в теле компонента, твой код станет сложным для понимания и отладки. Все изменения ref делай внутри useEffectuseCallback или обработчиков событий (например, onClick).

    Плохо:

    jsx
    function MyComponent() {
      const myRef = useRef(0);
      myRef.current = 123; // Побочный эффект прямо во время рендера! Так делать не стоит.
    
      return <div>...</div>;
    }

    Хорошо:

    jsx
    function MyComponent() {
      const myRef = useRef(0);
    
      useEffect(() => {
        myRef.current = 123; // Изменение в эффекте - безопасно.
      });
    
      const handleClick = () => {
        myRef.current = 456; // Изменение в обработчике события - безопасно.
      };
    
      return <div>...</div>;
    }
  2. ref это не состояние. Помни об этом. Если ты используешь ref для хранения данных, которые все-таки влияют на то, что ты видишь на экране, ты идешь по кривой дорожке. Интерфейс не будет обновляться синхронно с изменением ref.

  3. React не уведомляет о изменении ref. Поскольку обновление ref не вызывает рендер, у React нет встроенного способа «отреагировать» на это изменение. Если тебе нужна реакция на изменение какого-либо значения, это почти всегда случай для useState.

Практические задачи для закрепления

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

Задача 1: Плейер для видео

Создай компонент видеоплеера с двумя кнопками: «Play» и «Pause». Используй useRef для получения прямого доступа к DOM-элементу <video>, чтобы управлять воспроизведением.

Подсказка:

  • Используй useRef и атрибут ref на теге <video>.

  • У DOM-элемента <video> есть методы .play() и .pause().

Решение задачи 1:

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

function VideoPlayer() {
  const videoRef = useRef(null);

  const handlePlay = () => {
    videoRef.current?.play();
  };

  const handlePause = () => {
    videoRef.current?.pause();
  };

  return (
    <div>
      <video
        ref={videoRef}
        width="600"
        controls
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" // Используй любое тестовое видео
        type="video/mp4"
      >
        Ваш браузер не поддерживает видео тег.
      </video>
      <br />
      <button onClick={handlePlay}>Play</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

export default VideoPlayer;

Задача 2: Секундомер

Создай простой секундомер, который показывает прошедшее время в секундах. Должны быть кнопки «Старт», «Стоп» и «Сброс». Используй useRef для хранения ID интервала, чтобы его можно было корректно очистить.

Подсказка:

  • Время (в секундах) храни в состоянии (useState), так как оно должно отображаться.

  • ID таймера из setInterval храни в useRef. Почему? Потому что если бы он был в состоянии, его обновление вызывало бы лишний рендер.

  • Не забудь очищать интервал в useEffect при размонтировании компонента.

Решение задачи 2:

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

function Stopwatch() {
  const [time, setTime] = useState(0);
  const intervalRef = useRef(null); // Храним ID интервала

  const start = () => {
    // Если секундомер уже запущен, выходим
    if (intervalRef.current) return;

    intervalRef.current = setInterval(() => {
      setTime(prevTime => prevTime + 1);
    }, 1000);
  };

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null; // Обязательно обнуляем!
    }
  };

  const reset = () => {
    stop();
    setTime(0);
  };

  // Очистка при размонтировании компонента (очень важно!)
  useEffect(() => {
    // Функция очистки эффекта
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return (
    <div>
      <h2>Секундомер: {time} сек.</h2>
      <button onClick={start}>Старт</button>
      <button onClick={stop}>Стоп</button>
      <button onClick={reset}>Сброс</button>
    </div>
  );
}

export default Stopwatch;

Задача 3: Поле поиска с автоматическим фокусом при монтировании

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

Подсказка:

  • Используй useRef для получения доступа к input.

  • Используй useEffect с пустым массивом зависимостей, чтобы команда focus() выполнилась только один раз, при первом монтировании компонента.

Решение задачи 3:

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

function SearchBox() {
  const searchInputRef = useRef(null);

  useEffect(() => {
    // Этот эффект выполнится только после первого рендера (монтирования)
    if (searchInputRef.current) {
      searchInputRef.current.focus();
      console.log('Поле поиска получило фокус!');
    }
  }, []); // Пустой массив зависимостей = эффект только при монтировании

  return (
    <div>
      <input
        ref={searchInputRef}
        type="text"
        placeholder="Начните вводить запрос..."
      />
    </div>
  );
}

export default SearchBox;

Итоги

Ты только что освоил один из ключевых, хоть и не всегда очевидных, хуков React useRef. Давай резюмируем:

  • useRef возвращает мутируемый объект с единственным свойством .current.

  • Основное назначение №1. Прямой доступ к DOM-элементам через атрибут ref в JSX. Это твой портал в императивный мир браузера для задач вроде фокуса, скролла, анимаций и интеграции со сторонними библиотеками.

  • Основное назначение №2. Хранение мутируемых значений, которые должны сохраняться между рендерами, но не должны их вызывать. Это твой «секретный блокнот» для таймеров, предыдущих значений, кеша и прочих служебных данных.

  • Главное правило. Не используй useRef для данных, которые должны рендериться в UI. Для этого есть useState.

  • Изменяй ref.current только в обработчиках событий или эффектах, но не во время самого рендера.

Хук useRef дает контроль над «реальным» миром DOM и памятью компонента. Используй ее с умом и твои приложения станут гораздо мощнее и отзывчивее.

Чтобы пройти весь путь от новичка до уверенного React-разработчика, изучи полный курс по React для начинающих. Там тебя ждут все 30 уроков, углубленные практические задания, реальные проекты и многое другое.

Удачи в изучении!

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий