Skip to main content
  1. Frontends/
  2. React Guides/

Mastering React Refs: Moving Beyond forwardRef to Imperative Handles

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

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 any implies 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 install

The 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.

sequenceDiagram participant P as Parent Component participant C as Child Component participant D as DOM Node (Input) Note over P, D: Standard Data Flow (Declarative) P->>C: Passes props (value, onChange) C->>D: Renders attributes D-->>C: Fires Event C-->>P: Calls callback Note over P, D: Imperative Handle Flow P->>C: Creates Ref Note right of C: useImperativeHandle() hooks here C->>D: Internal DOM Ref P->>C: Calls ref.current.focusAndScroll() C->>D: Executes native .focus() C->>C: Executes custom scroll logic

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 forwardRef components. 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