In modern React development, the useImperativeHandle hook is a powerful way to personalize a component's exposed value and gives more control over its internal methods and properties. As a result, more efficient Component APIs can improve the product's flexibility and maintainability.
In this piece, the Social Discovery Group team´s insights dive into the best practices for using useImperativeHandle effectively to enhance React components.
React provides many hooks (the official documentation describes 17 hooks as of this writing) for managing state, effects, and interactions between components.
Among them, useImperativeHandle is a useful tool for creating a programmatic interface (API) for child components, which was added to React from version 16.8.0 onward.
useImperativeHandle allows you to customize what will be returned by the ref passed to a component. It works in tandem with forwardRef, which allows a ref to be passed to a child component.
useImperativeHandle(ref, createHandle, [deps]);
This hook allows external control of a component's behavior, which can be useful in certain situations, such as working with third-party libraries, complex animations, or components that require direct access to methods. However, it should be used cautiously, as it breaks React's declarative approach.
Let’s imagine we need to manipulate the DOM of a child component. Here's an example of how to do this using a ref.
import React, { forwardRef, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
// Use forwardRef to pass the ref to the input element
return <input ref={ref} {...props} />;
});
export default function App() {
const inputRef = useRef();
const handleFocus = () => {
inputRef.current.focus(); // Directly controlling the input
};
const handleClear = () => {
inputRef.current.value = ''; // Directly controlling the input value
};
return (
<div>
<CustomInput ref={inputRef} />
<button onClick={handleFocus}>Focus</button>
<button onClick={handleClear}>Clear</button>
</div>
);
}
And here’s how it can be achieved using useImperativeHandle.
import React, { useImperativeHandle, forwardRef, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
clear: () => {
inputRef.current.value = '';
},
}));
return <input ref={inputRef} {...props} />;
});
export default function App() {
const inputRef = useRef();
return (
<div>
<CustomInput ref={inputRef} />
<button onClick={inputRef.current.focus}>Focus</button>
<button onClick={inputRef.current.clear}>Clear</button>
</div>
);
}
As seen in the examples above, when using useImperativeHandle, the child component provides the parent with a set of methods that we define ourselves.
Using useImperativeHandle in advanced scenarios, such as in examples with animation, allows isolating complex behavior inside a component. This makes the parent component simpler and more readable, especially when working with animation or sound libraries.
import React, { useRef, useState, useImperativeHandle, forwardRef, memo } from "react";
import { Player } from '@lottiefiles/react-lottie-player'
import animation from "./animation.json";
const AnimationWithSound = memo(
forwardRef((props, ref) => {
const [isAnimating, setIsAnimating] = useState(false);
const animationRef = useRef(null);
const targetDivRef = useRef(null);
useImperativeHandle(
ref,
() => ({
startAnimation: () => {
setIsAnimating(true);
animationRef.current?.play()
updateStyles("start");
},
stopAnimation: () => {
animationRef.current?.stop()
updateStyles("stop");
},
}),
[]
);
const updateStyles = (action) => {
if (typeof window === 'undefined' || !targetDivRef.current) return;
if (action === "start") {
if (targetDivRef.current.classList.contains(styles.stop)) {
targetDivRef.current.classList.remove(styles.stop);
}
targetDivRef.current.classList.add(styles.start);
} else if (action === "stop") {
if (targetDivRef.current.classList.contains(styles.start)) {
targetDivRef.current.classList.remove(styles.start);
}
targetDivRef.current.classList.add(styles.stop);
}
};
return (
<div>
<Player
src={animation}
loop={isAnimating}
autoplay={false}
style={{width: 200, height: 200}}
ref={animationRef}
/>
<div ref={targetDivRef} className="target-div">
This div changes styles
</div>
</div>
);
})
);
export default function App() {
const animationRef = useRef();
const handleStart = () => {
animationRef.current.startAnimation();
};
const handleStop = () => {
animationRef.current.stopAnimation();
};
return (
<div>
<h1>Lottie Animation with Sound</h1>
<AnimationWithSound ref={animationRef} />
<button onClick={handleStart}>Start Animation</button>
<button onClick={handleStop}>Stop Animation</button>
</div>
);
}
In this example, the child component returns the methods startAnimation and stopAnimation, which encapsulate complex logic within themselves.
The error is not always noticeable immediately. For example, the parent component might frequently change props, and you might encounter a situation where an outdated method (with stale data) continues to be used.
Example error:
https://use-imperative-handle.levkovich.dev/deps-not-correct/wrong
const [count, setCount] = useState(0);
const increment = useCallback(() => {
console.log("Current count in increment:", count); // Shows old value
setCount(count + 1); // Are using the old value of count
}, [count]);
useImperativeHandle(
ref,
() => ({
increment, // Link to the old function is used
}),
[] // Array of dependencies do not include increment function
);
The right approach:
const [count, setCount] = useState(0);
useImperativeHandle(
ref,
() => ({
increment,
}),
[increment] // Array of dependencies include increment function
);
2. Missing dependency array
If the dependency array is not provided, React will assume that the object in useImperativeHandle should be recreated on every render. This can cause significant performance issues, especially if "heavy" computations are performed within the hook.
Example error:
useImperativeHandle(ref, () => {
// There is might be a difficult task
console.log("useImperativeHandle calculated again");
return {
focus: () => {}
}
}); // Array of dependencies is missing
The right approach:
useImperativeHandle(ref, () => {
// There is might be a difficult task
console.log("useImperativeHandle calculated again");
return {
focus: () => {}
}
}, []); // Array of dependencies is correct
Direct modification of ref.current disrupts React's behavior. If React attempts to update the ref, it can lead to conflicts or unexpected errors.
Example error:
useImperativeHandle(ref, () => {
// ref is mutated directly
ref.current = { customMethod: () => console.log("Error") };
});
The right approach:
useImperativeHandle(ref, () => ({
customMethod: () => console.log("Correct"),
}));
Calling methods provided via useImperativeHandle from useEffect or event handlers, assuming the ref is already available, can lead to errors — always check current before calling its methods.
Example error:
const increment = useCallback(() => {
childRef.current.increment();
}, [])
The right approach:
const increment = useCallback(() => {
if (childRef.current?.increment) {
childRef.current.increment()
}
}, [])
If useImperativeHandle returns methods that synchronously change state (e.g., starting an animation and simultaneously modifying styles), it may cause a "gap" between the visual state and internal logic. Ensure consistency between state and visual behavior, for example, by using effects (useEffect).
Example error:
useImperativeHandle(ref, () => ({
startAnimation: () => {
setState("running");
// Animation starts before the state changes
lottieRef.current.play();
},
stopAnimation: () => {
setState("stopped");
// Animation stops before the state changes
lottieRef.current.stop();
},
}));
The right approach:
useEffect(() => {
if (state === "running" && lottieRef.current) {
lottieRef.current.play();
} else if (state === "stopped" && lottieRef.current) {
lottieRef.current.stop();
}
}, [state]); // Triggered when the state changes
useImperativeHandle(
ref,
() => ({
startAnimation: () => {
setState("running");
},
stopAnimation: () => {
setState("stopped");
},
}),
[]
);
Using useImperativeHandle is justified in the following situations:
Controlling child component behavior: For example, to provide a focus or reset method for a complex input component.
Hiding implementation details: The parent component only receives the methods it needs, not the entire ref object.
Before using useImperativeHandle, ask yourself these questions:
By mastering the useImperativeHandle hook, React developers can create more efficient and maintainable components by selectively exposing methods. The techniques laid out by theSocial Discovery Group team can help developers improve their flexibility, streamline their component APIs, and enhance overall app performance.
Written by Sergey Levkovich, Senior Software Engineer at Social Discovery Group