Refs у React (useRef, createref, forwardref)
Refs у React - це об'єкти, які тримають пряме посилання на DOM-вузол або екземпляр компонента, поза циклом рендерингу.
Теорія
TL;DR
- Аналогія: ref - це прямий дзвінок до DOM-елемента. Черга рендерингу не задіяна
useRefповертає той самий об'єкт{ current }при кожному рендері. Зміна.currentрендер не викликаєcreateRefстворює новий об'єкт при кожному виклику, тому підходить лише для конструкторів класових компонентівforwardRefдозволяє батьківському компоненту передати свій ref у дочірнійuseImperativeHandleконтролює, що саме батьківський компонент отримує через цей ref- Правило: треба
focus(),play(), ID таймера або попереднє значення? Використовуй ref. Треба оновити UI? Використовуй state
Швидкий приклад
import { useRef } from 'react';
function VideoPlayer() {
const videoRef = useRef(null); // починається як { current: null }
const play = () => {
videoRef.current.play(); // прямий виклик DOM, рендеру немає
};
return (
<div>
<video ref={videoRef} src="demo.mp4" />
<button onClick={play}>Відтворити</button>
</div>
);
}Після монтування videoRef.current вказує на справжній елемент <video>. Кнопка викликає нативний video.play() без жодного оновлення стейту.
Головна відмінність від state
Зміна state запускає порівняння віртуального DOM і планує повторний рендер. Ref просто тримає .current - звичайну мутабельну властивість, за якою React не стежить. Ти її змінюєш, React не реагує, рендеру немає. React встановлює ref.current під час фази фіксації (commit phase), після того як реальний DOM вже оновлено. Тому refs підходять для прямої роботи з DOM або для зберігання мутабельних даних, які не впливають на те, що бачить користувач.
useRef vs createRef
useRef зберігає своє значення в memoizedState fiber-вузла. Той самий об'єкт повертається при кожному рендері, бо React не скидає хуки під час оновлень. createRef кожного разу виділяє новий { current: null }. У функціональному компоненті це означає новий об'єкт при кожному рендері, і ref ніколи не прикріплюється до елемента.
// Функціональний компонент
const ref = useRef(null); // той самий об'єкт при кожному рендері
const ref = createRef(); // новий об'єкт при кожному рендері - завжди nullcreateRef належить конструкторам класових компонентів, де він виконується рівно один раз.
Коли використовувати refs
- Фокус і вибір тексту:
inputRef.current.focus()після відкриття модального вікна або спрацювання клавіатурного скорочення - Медіа та canvas:
videoRef.current.play(),canvasRef.current.getContext('2d')для покадрового малювання - Мутабельні прапорці без рендеру: чи вдався автоплей, чи користувач прогорнув секцію, попереднє значення пропса
- ID таймерів: зберігай повернене значення
setInterval, щоб викликатиclearIntervalв очищенніuseEffect - Сторонні бібліотеки: Chart.js, Video.js, будь-який canvas-інструмент, якому потрібен реальний DOM-вузол під час ініціалізації
Я бачив форми, де стан пагінації зберігали в ref, щоб уникнути зайвих рендерів. Все працювало до моменту, коли треба було відобразити поточну сторінку, і вона завжди показувала 1. Якщо значення впливає на UI, йому місце в state.
Порівняння API
useRef | createRef | forwardRef | |
|---|---|---|---|
| Де використовувати | Функціональні компоненти | Класові компоненти | Будь-який компонент, що відкриває ref батьківському |
| Зберігається між рендерами | Так | Ні | Залежить від внутрішнього useRef |
| Викликає рендер | Ні | Ні | Ні |
| Типове використання | DOM-доступ, мутабельні значення | DOM-доступ у класових компонентах | Передача ref через межу компонента |
| Коли використовувати | Майже завжди | Лише в конструкторах класів | Коли батьківський компонент потребує прямого доступу до DOM дочірнього |
forwardRef
За замовчуванням пропс ref на кастомному компоненті просто зникає. React його не передає далі. forwardRef обгортає компонент і передає ref другим аргументом у функцію рендерингу:
import { forwardRef, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
function Parent() {
const inputRef = useRef(null);
return (
<div>
<CustomInput ref={inputRef} placeholder="Введи текст" />
<button onClick={() => inputRef.current.focus()}>Фокус</button>
</div>
);
}Тепер inputRef.current - це справжній <input>. Батьківський компонент може викликати будь-який DOM-метод на ньому.
useImperativeHandle
Повний доступ до DOM не завжди бажаний. useImperativeHandle дозволяє визначити точно, що отримає батьківський компонент:
import { forwardRef, useRef, useImperativeHandle } from 'react';
const Video = forwardRef((props, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
playPause: () =>
videoRef.current.paused
? videoRef.current.play()
: videoRef.current.pause(),
}), []);
return <video ref={videoRef} {...props} />;
});
function Player() {
const videoRef = useRef(null);
return (
<>
<Video ref={videoRef} src="demo.mp4" />
<button onClick={() => videoRef.current.playPause()}>Перемкнути</button>
</>
);
}Батьківський компонент отримує лише playPause. Реальний DOM-вузол залишається всередині дочірнього. Цей патерн стандартний у бібліотеках компонентів на кшталт React Player.
Як React прикріплює refs
React встановлює ref.current під час фази фіксації, після того як завершив запис у реальний DOM. До монтування ref.current дорівнює null. Після розмонтування React скидає його назад у null. Тому читати ref.current під час рендерингу - завжди отримуєш застарілий або порожній результат. Читай його всередині useEffect або обробників подій.
У React 18 з увімкненим StrictMode компоненти монтуються двічі в режимі розробки. useRef переживає обидва монтування, бо живе в тому самому fiber-об'єкті. Але ефекти очищаються і запускаються двічі. Якщо зберігаєш observer або підписку в ref - почисти її в функції повернення useEffect.
Типові помилки
- Читання
ref.currentпід час рендерингу
// Неправильно - ref.current тут null, елемента ще немає
function Component() {
const ref = useRef(null);
console.log(ref.current); // null
return <div ref={ref}>текст</div>;
}
// Правильно
useEffect(() => {
console.log(ref.current); // реальний DOM-вузол
}, []);- Зберігання стану UI в ref
// Неправильно - відображення застаріває, рендеру немає
const valueRef = useRef('');
valueRef.current = e.target.value;
// Правильно
const [value, setValue] = useState('');
setValue(e.target.value);createRefу функціональному компоненті
// Неправильно - новий ref при кожному рендері, ніколи не прикріплюється
function Bad() {
const ref = createRef();
return <input ref={ref} />;
}
// Правильно
function Good() {
const ref = useRef(null);
return <input ref={ref} />;
}- Відсутня перевірка на null перед використанням
ref.current
// Неправильно - крашнеться, якщо елемент ще не змонтовано
inputRef.current.focus();
// Правильно
inputRef.current?.focus();forwardRefбезuseImperativeHandleвідкриває весь DOM-вузол
Батьківський компонент може читати .value, викликати .blur(), напряму змінювати .style. Для простого внутрішнього компонента це нормально. Для компонента у спільній бібліотеці або дизайн-системі завжди обмежуй API через useImperativeHandle.
Де зустрічається в реальних проектах
- React Aria (Adobe):
useRefдля управління фокусом у доступних (accessible) модальних вікнах і меню - React Hook Form: зберігає refs полів для валідації без ре-рендерів на кожне натискання клавіші (10M+ завантажень npm на тиждень)
- Recharts:
forwardRefдозволяє батьківським компонентам прикріплювати resize observers до контейнерів графіків - Framer Motion: refs керують анімаціями і тригерами прокрутки
- Video.js: передає ref у
<canvas>для WebGL-оверлея
Питання на співбесіді
Q: Яка різниця між useRef і createRef?
A: useRef зберігає об'єкт у стані хуків fiber і повертає те саме посилання при кожному рендері. createRef виділяє новий { current: null } при кожному виклику. У функціональному компоненті це означає: ref скидається при кожному рендері і ніколи не вказує на корисний елемент.
Q: Коли встановлюється ref.current?
A: Під час фази фіксації (commit phase), після того як React оновив реальний DOM. До монтування - null. Після розмонтування React скидає його назад у null. Читати під час рендерингу немає сенсу.
Q: Навіщо використовувати useImperativeHandle разом з forwardRef?
A: Щоб відкрити контрольований API замість повного DOM-вузла. Батьківський компонент отримує лише ті методи, які ти визначив, що запобігає випадковій прямій маніпуляції DOM ззовні компонента.
Q: Чи можуть refs спричинити витік пам'яті?
A: Так. Якщо зберігаєш ID інтервалу, observer або слухача подій у ref і не очищаєш його в поверненні useEffect, ресурс залишається живим після розмонтування компонента.
Q: Як refs поводяться в конкурентному React 18?
A: Refs прикріплюються після того, як commitRoot завершує всю роботу. Нарізання часу (time slicing) не впливає на стабільність refs, бо вони живуть поза фазою мутацій fiber. Призупинений рендер з низьким пріоритетом не скидає ref.current.
Приклади
Визначення успішності автоплею
import { useRef, useEffect } from 'react';
function AutoplayVideo({ src }) {
const videoRef = useRef(null);
const wasPlayingRef = useRef(false); // мутабельний прапорець, рендер не потрібен
useEffect(() => {
const video = videoRef.current;
video
.play()
.then(() => {
wasPlayingRef.current = true; // збережено без оновлення стейту
})
.catch(() => {
console.log('Автоплей заблоковано браузером');
});
}, [src]);
return <video ref={videoRef} src={src} muted />;
}Chrome блокує автоплей для відео без muted, тому цей атрибут обов'язковий. wasPlayingRef зберігає результат без повторного рендеру, бо статус автоплею не потрібно показувати у UI.
Кастомний хук для отримання попереднього значення
import { useRef, useEffect, useState } from 'react';
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // запускається після рендеру
}, [value]);
return ref.current; // повертає значення з попереднього рендеру
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Зараз: {count}, раніше: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}useEffect запускається після рендеру. Тому коли компонент повертає результат, ref.current ще тримає значення попереднього рендеру. При наступному рендері це старе значення і повертається. Вся магія - в асиметрії часу виконання.
Відеоплеєр із контрольованим imperative API
import { forwardRef, useRef, useImperativeHandle } from 'react';
const VideoPlayer = forwardRef(({ src }, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
seek: (seconds) => { videoRef.current.currentTime = seconds; },
}), []);
return <video ref={videoRef} src={src} />;
});
function App() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="demo.mp4" />
<button onClick={() => playerRef.current.play()}>Відтворити</button>
<button onClick={() => playerRef.current.seek(30)}>+30с</button>
</>
);
}Батьківський компонент має play, pause і seek. Він не може читати .currentTime, .buffered чи .duration. Ця межа і є ціллю useImperativeHandle.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.