Navigate
Search topics, hooks, and fundamentals
Think of useImperativeHandle as a bank teller window. Instead of handing the customer the vault key, you give them a controlled window β deposit, withdraw, check balance. The vault stays locked.
π¦ Theme: Bank Vault
Your child component is a bank vault. Passing a raw ref is like leaving the vault door wide open β the parent can reach in and touch anything. useImperativeHandle is the teller window: you expose only specific operations (focus, scroll, reset) through a controlled slot while the vault internals stay locked.
When you forward a raw ref, the parent gets the vault key β full access to every DOM property and method. Approved transactions and dangerous tampering look exactly the same.
Approved operations:
But anyone can also...
Parent can access ALL of these:
useImperativeHandle replaces the raw ref with a curated API. The parent can only call the transactions you approve β everything else is undefined.
Approved transactions:
Try unauthorized access:
Exposed:
Blocked:
const SecureVault = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
deposit() { inputRef.current.focus(); },
withdraw() { inputRef.current.value = ""; },
checkBalance() {
inputRef.current.scrollIntoView({ behavior: "smooth" });
},
}));
return <input ref={inputRef} />;
// β internal ref β customer can't access it!
});
// Customer gets ONLY these 3 transactions:
ref.current.deposit(); // β Works
ref.current.withdraw(); // β Works
ref.current.checkBalance(); // β Works
ref.current.style; // β undefined
ref.current.remove(); // β undefineduseImperativeHandle is your bank's teller window β the customer gets deposit, withdraw, and check balance, not the vault key.
useImperativeHandle shines when a parent needs to trigger behaviour inside a child without reaching into its internals. Each playground below demonstrates a different kind of teller window.
Internal component state:
Lock status
LockedItems stored
0
Auto-lock timer
β
Parent controls (via ref):
The parent can only call open, close, store, and getCount. It cannot access the internal lock mechanism, timer state, or stored items directly.
const SafeDepositBox = forwardRef((props, ref) => {
const [isLocked, setIsLocked] = useState(true);
const [items, setItems] = useState([]);
useImperativeHandle(ref, () => ({
open() { setIsLocked(false); /* start auto-lock */ },
close() { setIsLocked(true); },
store(item) {
if (!isLocked) setItems(prev => [...prev, item]);
},
getCount() { return items.length; },
}));
// Parent CANNOT access isLocked, items, or timer
return <div>...</div>;
});The loan officer (parent) calls stepRef.current.validate() before advancing. Each form step manages its own state β the parent only sees the imperative API.
const FormStep = forwardRef(({ fields }, ref) => {
const [values, setValues] = useState({});
const [errors, setErrors] = useState({});
useImperativeHandle(ref, () => ({
validate() {
// Check all fields, set errors, return boolean
return Object.keys(errors).length === 0;
},
reset() { setValues({}); setErrors({}); },
getData() { return { ...values }; },
}));
return <form>...</form>;
});
// Loan officer (parent):
function handleNext() {
if (stepRef.current.validate()) {
goToNextStep();
}
}Answer these questions to decide if useImperativeHandle fits your situation.
Is a child component exposing a ref to its parent via forwardRef?
Does the parent need only specific operations, not full DOM access?
Do you want to protect the component's internal implementation from outside interference?
// DO lock down β parent only needs play/pause
useImperativeHandle(ref, () => ({
play() { videoRef.current.play(); },
pause() { videoRef.current.pause(); },
}));
// DO lock down β expose validate, not internal form state
useImperativeHandle(ref, () => ({
validate() { return checkFields(); },
getData() { return { ...values }; },
}));
// DON'T β parent genuinely needs the DOM node
const divRef = useRef(null); // no imperative handle neededThese are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
const Modal = forwardRef((props, ref) => {
const [isOpen, setIsOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false),
}));
if (!isOpen) return null;
return <div className="modal">{props.children}</div>;
});
// Parent: ref.current.open() β imperative and unnecessaryconst FormInput = forwardRef((props, ref) => {
const [value, setValue] = useState("");
const [error, setError] = useState("");
const [touched, setTouched] = useState(false);
useImperativeHandle(ref, () => ({
value, error, touched, // leaking raw state
setValue, setError, // leaking setters
validate, clear, reset,
}), [value, error, touched]);
});const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
scrollIntoView: () => inputRef.current?.scrollIntoView(),
})); // no dependency array β recreates every render
return <input ref={inputRef} />;
});