useImperativeHandle
is an advanced React hook that allows you to customize the instance value that’s exposed when using ref
with functional components. This is particularly useful when you need to:
- Expose specific methods to parent components
- Hide internal implementation details
- Create imperative APIs for reusable components
Basic Usage Pattern
1. Forwarding Refs
const CustomInput = forwardRef(function CustomInput(props, ref) {
const inputRef = useRef();
// Expose focus method to parent
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
},
scrollIntoView: () => {
inputRef.current.scrollIntoView();
}
}));
return <input {...props} ref={inputRef} />;
});
// Parent component usage
function Parent() {
const inputRef = useRef();
const handleClick = () => {
inputRef.current.focus(); // Calls the exposed method
};
return (
<>
<CustomInput ref={inputRef} />
<button onClick={handleClick}>Focus Input</button>
</>
);
}
Advanced Patterns
2. Combining with Component Methods
const VideoPlayer = forwardRef((props, ref) => {
const videoRef = useRef();
const [isPlaying, setIsPlaying] = useState(false);
useImperativeHandle(ref, () => ({
play: () => {
videoRef.current.play();
setIsPlaying(true);
},
pause: () => {
videoRef.current.pause();
setIsPlaying(false);
},
togglePlay: () => {
if (isPlaying) {
videoRef.current.pause();
setIsPlaying(false);
} else {
videoRef.current.play();
setIsPlaying(true);
}
},
isPlaying
}), [isPlaying]); // Recreate when isPlaying changes
return <video ref={videoRef} {...props} />;
});
// Usage
function App() {
const playerRef = useRef();
return (
<div>
<VideoPlayer
ref={playerRef}
src="/video.mp4"
/>
<button onClick={() => playerRef.current.togglePlay()}>
Toggle Play
</button>
</div>
);
}
3. Dynamic Method Exposure
const SmartForm = forwardRef(({ fields }, ref) => {
const fieldRefs = useRef({});
useImperativeHandle(ref, () => {
const methods = {
getValues: () => {
return Object.fromEntries(
Object.entries(fieldRefs.current).map(([name, ref]) =>
[name, ref.value]
) ); }, validate: () => { return Object.values(fieldRefs.current).every( ref => ref.reportValidity() ); }, reset: () => { Object.values(fieldRefs.current).forEach(ref => { ref.value = ”; }); } }; // Conditionally expose submit if form has submit handler if (props.onSubmit) { methods.submit = () => { if (methods.validate()) { props.onSubmit(methods.getValues()); } }; } return methods; }, [fields, props.onSubmit]); return ( <form> {fields.map(field => ( <input key={field.name} ref={el => fieldRefs.current[field.name] = el} name={field.name} required={field.required} /> ))} </form> ); });
TypeScript Integration
4. Strongly Typed Refs
interface PlayerRef {
play: () => void;
pause: () => void;
togglePlay: () => void;
isPlaying: boolean;
}
const VideoPlayer = forwardRef<PlayerRef, VideoProps>(
function VideoPlayer(props, ref) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
useImperativeHandle(ref, () => ({
play: () => {
videoRef.current?.play();
setIsPlaying(true);
},
pause: () => {
videoRef.current?.pause();
setIsPlaying(false);
},
togglePlay: () => {
if (isPlaying) {
videoRef.current?.pause();
setIsPlaying(false);
} else {
videoRef.current?.play();
setIsPlaying(true);
}
},
isPlaying
}), [isPlaying]);
return <video ref={videoRef} {...props} />;
}
);
// Usage with type safety
function App() {
const playerRef = useRef<PlayerRef>(null);
// playerRef.current will have autocomplete for PlayerRef methods
return <VideoPlayer ref={playerRef} />;
}
Real-World Use Cases
5. Custom Scrollable Component
const ScrollableContainer = forwardRef(({ children }, ref) => {
const containerRef = useRef();
useImperativeHandle(ref, () => ({
scrollToTop: () => {
containerRef.current.scrollTop = 0;
},
scrollToBottom: () => {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
},
scrollToPosition: (position) => {
containerRef.current.scrollTop = position;
},
getScrollPosition: () => {
return containerRef.current.scrollTop;
}
}));
return (
<div ref={containerRef} style={{ overflowY: 'auto', height: '300px' }}>
{children}
</div>
);
});
// Usage
function ChatWindow() {
const scrollRef = useRef();
const [messages, setMessages] = useState([]);
useEffect(() => {
if (messages.length) {
scrollRef.current.scrollToBottom();
}
}, [messages]);
return (
<ScrollableContainer ref={scrollRef}>
{messages.map(msg => (
<Message key={msg.id} text={msg.text} />
))}
</ScrollableContainer>
);
}
6. Form Validation Component
const ValidatedForm = forwardRef(({ onSubmit }, ref) => {
const formRef = useRef();
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
const inputs = formRef.current.elements;
Array.from(inputs).forEach(input => {
if (input.required && !input.value) {
newErrors[input.name] = 'Required field';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
useImperativeHandle(ref, () => ({
submit: () => {
if (validate()) {
const formData = new FormData(formRef.current);
onSubmit(Object.fromEntries(formData));
}
},
validate,
getErrors: () => errors,
reset: () => {
formRef.current.reset();
setErrors({});
}
}), [errors, onSubmit]);
return (
<form ref={formRef}>
{/* Form fields */}
</form>
);
});
// Usage
function CheckoutPage() {
const formRef = useRef();
const handleSubmit = () => {
formRef.current.submit();
};
return (
<>
<ValidatedForm
ref={formRef}
onSubmit={data => console.log(data)}
/>
<button onClick={handleSubmit}>Place Order</button>
</>
);
}
Best Practices
- Minimal Exposure: Only expose necessary methods to parent components
- Stable References: Wrap exposed functions with
useCallback
if they’re dependencies - Dependency Arrays: Include all dependencies that affect exposed values
- Document APIs: Clearly document the exposed methods and their signatures
- Type Safety: Use TypeScript to define the ref interface
- Avoid Overuse: Consider props and callbacks before reaching for imperative handles
Performance Considerations
const HeavyComponent = forwardRef((props, ref) => {
const internalRef = useRef();
const [state, setState] = useState();
// Optimize by memoizing the exposed object
useImperativeHandle(ref, () => {
return {
doSomething: (value) => {
// Heavy computation
const result = expensiveCalculation(value);
internalRef.current.process(result);
setState(result);
},
getState: () => state
};
}, [state]); // Only recreate when state changes
return <div ref={internalRef}>...</div>;
});
Common Pitfalls
- Forgetting
forwardRef
: Must wrap component to useuseImperativeHandle
- Stale Closures: Include all dependencies in the dependency array
- Over-exposing: Exposing too much breaks component encapsulation
- Direct DOM Manipulation: Avoid exposing raw DOM nodes for security/maintainability
Testing Components with useImperativeHandle
test('should expose focus method', () => {
const ref = { current: null };
render(<CustomInput ref={ref} />);
// Test exposed method
ref.current.focus();
expect(document.activeElement).toBeInstanceOf(HTMLInputElement);
});
test('should validate form', () => {
const ref = { current: null };
render(<ValidatedForm ref={ref} />);
// Test validation
expect(ref.current.validate()).toBe(false);
// Fill required field
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'test' } });
expect(ref.current.validate()).toBe(true);
});
useImperativeHandle
is a powerful tool when you need to create controlled components with imperative APIs. While React’s declarative model should be preferred in most cases, this hook provides an escape hatch for situations where direct imperative control is necessary.