It’s 2026. React 19 has settled into the ecosystem, and the way we handle references (refs) has evolved significantly. If you’ve been writing React for a few years, you likely remember the ritual of wrapping components in forwardRef just to pass a DOM node up to a parent.
While the declarative nature of React—props down, events up—covers 95% of use cases, there are moments where you simply must get imperative. Maybe you need to manage focus on a complex input, trigger a media playback, or orchestrate an animation that React’s state model finds cumbersome.
In this guide, we aren’t just looking at syntax; we are looking at encapsulation. We will explore how refs work in the modern era, why forwardRef is largely a thing of the past, and how useImperativeHandle is the secret weapon for building robust, reusable UI libraries.
Prerequisites and Environment #
Before we dive into the code, ensure your development environment is set up for modern React development.
- Node.js: v22.x or later (LTS recommended).
- React: v19.0+.
- TypeScript: v5.5+. We will use strong typing, as passing refs around
anyimplies a massive technical debt risk.
If you are setting up a fresh sandbox:
npm create vite@latest react-refs-demo -- --template react-ts
cd react-refs-demo
npm installThe Evolution of Ref Forwarding #
To understand where we are, we have to look at the architecture of component communication.
The Shift in React 19 #
For years, the ref prop was special. It didn’t appear in the props object. To get around this, we used React.forwardRef.
In 2025/2026, React simplified this. Ref is just a prop. Functional components can now accept ref directly in their arguments list without the higher-order component wrapper. However, understanding the pattern of forwarding is still crucial, especially when we start customizing what that ref actually exposes.
Visualizing the Control Flow #
Here is how data and control flow when using standard props versus Imperative Handles.
Step 1: The Modern Ref Pattern (No forwardRef)
#
Let’s start with the baseline. You have a custom TextInput component, and the parent needs to focus it when a validation error occurs.
In the past, you’d wrap this in forwardRef. Now, we just destructure it.
File: src/components/ModernInput.tsx
import React, { useRef } from 'react';
interface ModernInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
// In React 19, we can type the ref explicitly in props if needed,
// or rely on React's updated types.
ref?: React.Ref<HTMLInputElement>;
}
const ModernInput = ({ label, ref, ...props }: ModernInputProps) => {
return (
<div className="flex flex-col gap-2 mb-4">
<label className="text-sm font-semibold text-gray-700">{label}</label>
<input
ref={ref}
{...props}
className="p-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
);
};
export default ModernInput;Usage in Parent:
import { useRef } from 'react';
import ModernInput from './components/ModernInput';
const App = () => {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
// Direct access to the DOM node
inputRef.current?.focus();
inputRef.current?.style.setProperty('background-color', '#fff0f0');
};
return (
<div className="p-8">
<ModernInput
label="Username"
ref={inputRef}
placeholder="Enter username"
/>
<button
onClick={handleClick}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
>
Focus Input
</button>
</div>
);
};This works, but it exposes the entire HTMLInputElement to the parent. The parent can change styles, add event listeners manually, or do other dangerous things. This breaks encapsulation.
Step 2: Controlling Exposure with useImperativeHandle
#
This is where the “Senior” engineering comes in. Often, you don’t want to give the parent the DOM node. You want to give the parent an API to control the child.
Let’s build a SmartForm that only exposes two methods: reset() and triggerValidation(). The parent doesn’t need to know how the form resets (maybe it clears inputs, maybe it resets API state), it just needs to request the action.
File: src/components/SmartForm.tsx
import React, { useImperativeHandle, useRef, useState } from 'react';
// 1. Define the Handle interface (The API we expose)
export interface SmartFormHandle {
reset: () => void;
validate: () => boolean;
focusFirstError: () => void;
}
interface SmartFormProps {
onSubmit: (data: Record<string, string>) => void;
// React 19 allows ref in props, but we type it with our custom handle
ref?: React.Ref<SmartFormHandle>;
}
const SmartForm = ({ onSubmit, ref }: SmartFormProps) => {
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 2. The Magic Hook: Customizing the exposed instance
useImperativeHandle(ref, () => {
return {
reset: () => {
setValue('');
setError(null);
},
validate: () => {
if (value.length < 3) {
setError('Too short!');
return false;
}
setError(null);
return true;
},
focusFirstError: () => {
inputRef.current?.focus();
}
};
}, [value]); // Dependency array: recreate handle if value changes (usually empty is fine)
return (
<div className="border p-4 rounded shadow-sm max-w-md">
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
className={`w-full p-2 border ${error ? 'border-red-500' : 'border-gray-300'} rounded`}
/>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
<button
onClick={() => onSubmit({ value })}
className="mt-2 w-full bg-green-500 text-white py-1 rounded"
>
Submit Internal
</button>
</div>
);
};
export default SmartForm;Implementing the Consumer #
Now the parent component interacts with a SmartFormHandle, not an HTMLDivElement or HTMLInputElement.
import { useRef } from 'react';
import SmartForm, { SmartFormHandle } from './components/SmartForm';
const ParentContainer = () => {
// TypeScript knows exactly what methods are available
const formRef = useRef<SmartFormHandle>(null);
const handleExternalReset = () => {
// We are calling a function defined INSIDE the child
formRef.current?.reset();
};
const handleExternalSubmit = () => {
const isValid = formRef.current?.validate();
if (!isValid) {
formRef.current?.focusFirstError();
} else {
console.log("Form is valid, proceeding...");
}
};
return (
<div className="p-8 bg-gray-50">
<h2 className="text-xl font-bold mb-4">Imperative Handle Demo</h2>
<SmartForm onSubmit={(data) => console.log(data)} ref={formRef} />
<div className="flex gap-4 mt-6">
<button
onClick={handleExternalReset}
className="px-4 py-2 bg-gray-600 text-white rounded"
>
Reset Form from Parent
</button>
<button
onClick={handleExternalSubmit}
className="px-4 py-2 bg-indigo-600 text-white rounded"
>
Validate from Parent
</button>
</div>
</div>
);
};
export default ParentContainer;Comparison: Direct Refs vs. Imperative Handles #
When should you use which? Here is a breakdown of the architectural decisions involved.
| Feature | Direct Ref (Prop) | Imperative Handle |
|---|---|---|
| Complexity | Low | Medium |
| Access Level | Full raw DOM node access | Restricted, custom API only |
| Encapsulation | Leaky. Parent depends on internal DOM structure. | Strong. Child decides what to expose. |
| Refactoring | Risky. Changing input to textarea might break parent. |
Safe. Internal implementation changes don’t affect API. |
| Use Case | Simple focus management, scrolling, measuring size. | Complex components (Video players, Rich Text Editors, Canvas). |
Pitfalls and Performance in Production #
While useImperativeHandle is powerful, it is technically an escape hatch from React’s declarative data flow. Here are some “gotchas” I’ve seen in production codebases.
1. Overusing Imperative Logic #
If you find yourself creating handles for setModalOpen(true) or updateData(newData), you are fighting React. State should flow down via props. Only use handles for things that cannot be achieved easily via props (like focus, scrollIntoView, or triggering a specific animation instance).
2. Dependency Array Mistakes #
In useImperativeHandle(ref, createHandle, [deps]), the dependency array determines when the handle object is re-created.
- If you omit dependencies, the handle might close over stale state values.
- If you include too many, the handle reference changes on every render, which might trigger
useEffects in the parent that depend on that ref.
3. TypeScript Complexity #
Typing forwardRef was historically painful in TypeScript. With React 19, typing the ref prop is easier, but defining the Handle Interface (like SmartFormHandle above) is mandatory. Always export this interface so the parent can import it for useRef<Interface>(null).
4. Null Checks #
Refs are mutable containers that start as null. Always optional chain (?.) your calls: ref.current?.doSomething(). In a useEffect, you should check if the ref exists before setting up listeners.
Conclusion #
The shift in React 19 to treat ref as a standard prop has simplified the syntax, but the architectural pattern remains the same. Use direct refs when you need simple DOM access, but reach for useImperativeHandle when you are building reusable, black-box components that need to expose a controlled public API.
This pattern is particularly vital for Design Systems where you want to allow consumers to focus an input, but you don’t want them messing with the aria-labels or internal event listeners you’ve carefully set up.
Next Steps:
- Audit your legacy
forwardRefcomponents. Can they be simplified to standard props? - Identify components where parents are reaching too deep into the DOM (e.g.,
ref.current.children[0].focus()). Refactor these to use Imperative Handles for a cleaner API.
Happy coding, and keep your component boundaries clean