Skip to main content

Чому useImperativeHandle потрібен у React

useImperativeHandle - це хук React, який дозволяє визначити, що саме батьківський компонент може робити через ref на дочірній компонент, замість того щоб відкривати весь DOM-вузол.

Теорія

TL;DR

  • Думай про це як про контрольований вихід за правила: дані в React течуть вниз через props, але цей хук дає батьку можливість напряму викликати методи дитини
  • Без нього ref на дочірньому компоненті відкриває батьку доступ до всього на DOM-вузлі. З ним ти сам визначаєш публічний API
  • Завжди паруй з forwardRef, без нього параметр ref буде undefined
  • Використовуй коли батькові потрібно викликати дії (focus(), reset(), scrollToTop()), а не читати стан
  • Правило вибору: якщо тягнешся до ref.current.internalState, тобі, мабуть, потрібен useImperativeHandle

Швидкий приклад

jsx
// Дитина відкриває тільки те, що потрібно батьку const Input = forwardRef((props, ref) => { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), clear: () => { inputRef.current.value = ''; } })); return <input ref={inputRef} />; }); // Батько може викликати тільки focus() і clear() const Parent = () => { const inputRef = useRef(); return ( <> <Input ref={inputRef} /> <button onClick={() => inputRef.current.focus()}>Focus</button> <button onClick={() => inputRef.current.clear()}>Clear</button> </> ); };

Батько може викликати focus() і clear(), і більше нічого. Він не зможе випадково викликати inputRef.current.removeChild() або зчитати внутрішні властивості DOM-вузла.

Навіщо це потрібно: інкапсуляція

Без useImperativeHandle ref на дочірньому компоненті відкриває батьку прямий доступ до DOM-вузла. Всі властивості, всі методи, всі внутрішні деталі. Для звичайного <input> це нормально, але не для компонента зі своїм станом і логікою.

useImperativeHandle повертає контроль тобі. Ти сам вирішуєш що відкривати. Батько отримує чистий API. Внутрішню реалізацію можна пізніше переробити, не ламаючи нічого зовнішнього.

Як це працює зсередини

useImperativeHandle(ref, createHandle, deps) зберігає об'єкт, який повертає createHandle(), у ref.current. Коли масив залежностей змінюється, React викликає createHandle() знову і замінює збережене значення. Сам ref - це просто { current: someValue }. Ніякої магії, тільки контрольоване присвоєння до мутабельного контейнера.

Коли використовувати

  • Батьку потрібно викликати методи-дії на дитині (focus(), reset(), play(), validate())
  • Дитина керує складним внутрішнім станом, до якого батько не повинен мати прямого доступу
  • Обгортаєш сторонню бібліотеку (відеоплеєр, datepicker, rich text editor), де API бібліотеки за природою є imperative
  • Будуєш багаторазові компоненти з контрольованим, стабільним зовнішнім інтерфейсом

Для передачі даних вниз достатньо props. Для перемикання видимості або анімацій - стан і CSS-переходи чистіші.

Поширені помилки

Помилка 1: Відкриваєш забагато

jsx
// Неправильно - батько може напряму змінювати внутрішній стан useImperativeHandle(ref, () => ({ inputRef, // сирий DOM-доступ витікає назовні internalState, // батько може зламати логіку компонента _privateMethod })); // Правильно - відкривай тільки публічний API useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), getValue: () => inputRef.current.value }));

Якщо відкрити inputRef напряму, батько може викликати inputRef.current.removeChild(). А якщо пізніше переробити внутрішню реалізацію, весь код батька, що торкається цих деталей, зламається.

Помилка 2: Пропущені залежності і застарілі замикання (stale closures)

jsx
const [count, setCount] = useState(0); // Неправильно - getCount() завжди повертає 0 useImperativeHandle(ref, () => ({ getCount: () => count })); // немає масиву залежностей // Правильно useImperativeHandle(ref, () => ({ getCount: () => count }), [count]);

Метод виконується нормально, повертає значення, помилки немає. Але це застаріле замикання. Дебажити болісно, бо жодного сигналу про проблему немає, просто неправильні дані.

Помилка 3: Imperative API для стану, який має бути в props

jsx
// Неправильно - imperative контроль модалки const Modal = forwardRef((props, ref) => { const [isOpen, setIsOpen] = useState(false); useImperativeHandle(ref, () => ({ open: () => setIsOpen(true), close: () => setIsOpen(false) })); return isOpen ? <div>Modal</div> : null; }); // Правильно - контрольований компонент const Modal = ({ isOpen, onClose }) => ( isOpen ? <div>Modal <button onClick={onClose}>X</button></div> : null );

Imperative версія робить батька складнішим для розуміння. Стан розкиданий по imperative-викликах. Time-travel debugging ламається. Якщо батько має контролювати видимість, це має бути prop.

Помилка 4: Не обгорнув у forwardRef

jsx
// Неправильно - параметр ref буде undefined const Input = (props, ref) => { useImperativeHandle(ref, () => ({ focus: () => {} })); return <input />; }; // Правильно const Input = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus: () => {} })); return <input />; });

Без forwardRef функціональні компоненти не отримують ref другим параметром. Він буде undefined, і useImperativeHandle нічого не зможе до нього прикріпити.

Помилка 5: Умовний виклик хука

jsx
// Неправильно - порушує правила хуків const Input = forwardRef((props, ref) => { if (props.disabled) { useImperativeHandle(ref, () => ({})); } return <input />; }); // Правильно - завжди виклик, регулюй що відкривати const Input = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus: props.disabled ? undefined : () => inputRef.current.focus() })); return <input />; });

React відслідковує хуки по порядку виклику. Умовний виклик дає помилку «Hooks called in different order».

Де зустрічається в реальних проектах

  • React Hook Form - відкриває focus(), setValue() на refs полів для imperative-валідації
  • Material-UI - TextField і Dialog відкривають imperative-методи через refs
  • Framer Motion - управління анімаціями через refs: play/pause/seek
  • Monaco Editor - getValue(), setValue(), layout() на ref редактора
  • Відео та аудіо плеєри - play(), pause(), seek() за природою є imperative
  • Rich text editors - focus(), getContent(), setContent() через refs

Я бачив команди, що тягнулися до useImperativeHandle там, де простий callback-prop вирішив би проблему. Перед використанням перевір: чи може батько просто передати onValidate колбек вниз?

Питання на співбесіді

Q: Чому б просто не використати ref напряму на DOM-елементі?


A: Можна, але тоді батько отримує доступ до всього на DOM-вузлі. useImperativeHandle дає змогу додати власну логіку до відкритих методів, наприклад аналітику або валідацію перед фокусом, і приховати внутрішні деталі.

Q: Чи можна використати useImperativeHandle без forwardRef?


A: Ні. Без forwardRef параметр ref в функціональному компоненті буде undefined. Хукові нема до чого прикріплюватись.

Q: Якщо відкритий метод оновлює стан у дитині, чи перерендериться батько?


A: Ні. Батько не перерендерується якщо його власний стан не змінився. Дитина перерендерується через оновлення стану всередині неї. Батько побачить нові дані тільки якщо викличе ще один метод для їх читання.

Q (рівень senior): У формі 10 полів, кожне відкриває validate() через useImperativeHandle. Батько викликає всі 10 в циклі при сабміті. Яка проблема з продуктивністю виникне і як її вирішити?


A: Кожен виклик validate() може тригерити оновлення стану в полі, що призводить до 10 окремих рендерів. Рішення: батчувати оновлення через flushSync, або збирати результати без змін стану (зберігати в ref, оновити стан один раз після всіх перевірок). Краще рішення: використати React Hook Form або Formik, які обробляють це без imperative refs.

Приклади

Кастомний input з focus і clear

jsx
import { useRef, useImperativeHandle, forwardRef } from 'react'; const CustomInput = forwardRef((props, ref) => { const inputRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), clear: () => { inputRef.current.value = ''; } })); return <input ref={inputRef} {...props} />; }); export default function App() { const ref = useRef(null); return ( <div> <CustomInput ref={ref} placeholder="Введи текст" /> <button onClick={() => ref.current.focus()}>Focus</button> <button onClick={() => ref.current.clear()}>Clear</button> </div> ); }

У ref.current тільки focus і clear. Батько не може торкнутись DOM-вузла напряму, не може викликати .remove(), не може зчитати .offsetHeight. API-поверхня рівно така, яку ти обрав.

Валідація форми з кількома полями

jsx
const FormField = forwardRef(({ name, validate }, ref) => { const [value, setValue] = useState(''); const [error, setError] = useState(''); useImperativeHandle(ref, () => ({ getValue: () => value, validate: () => { const err = validate(value); setError(err); return !err; // true якщо валідне }, reset: () => { setValue(''); setError(''); } }), [value, validate]); // deps тримають методи актуальними return ( <div> <input value={value} onChange={(e) => setValue(e.target.value)} /> {error && <span className="error">{error}</span>} </div> ); }); const Form = () => { const emailRef = useRef(); const passwordRef = useRef(); const handleSubmit = () => { const emailOk = emailRef.current.validate(); const passwordOk = passwordRef.current.validate(); if (emailOk && passwordOk) { console.log('Відправляємо...'); } }; return ( <> <FormField ref={emailRef} name="email" validate={(v) => v.includes('@') ? '' : 'Невірний email'} /> <FormField ref={passwordRef} name="password" validate={(v) => v.length >= 8 ? '' : 'Мінімум 8 символів'} /> <button onClick={handleSubmit}>Відправити</button> </> ); };

Кожне поле керує своїм станом і логікою валідації. Батько викликає validate() і отримує boolean. Внутрішній стан нікуди не витікає. Масив залежностей [value, validate] тримає методи синхронізованими з поточними значеннями.

Edge case із застарілим замиканням

jsx
const VideoPlayer = forwardRef((props, ref) => { const [isPlaying, setIsPlaying] = useState(false); const videoRef = useRef(); // Неправильно: немає deps, getStatus() завжди повертає false useImperativeHandle(ref, () => ({ play: () => { videoRef.current.play(); setIsPlaying(true); }, getStatus: () => isPlaying // захоплено застаріле значення })); // Правильно: додаємо isPlaying щоб метод завжди читав актуальне значення useImperativeHandle(ref, () => ({ play: () => { videoRef.current.play(); setIsPlaying(true); }, getStatus: () => isPlaying }), [isPlaying]); return <video ref={videoRef} src={props.src} />; });

З неправильною версією player.current.getStatus() повертає false навіть після виклику play(). Метод виконується, повертає значення, жодної помилки. Саме тому застарілі замикання так важко помітити на code review.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?