Type something to search...
forwardRef and useImperativeHandle Explained

forwardRef and useImperativeHandle Explained

You build a nice TextField component that wraps an input with a label and error message. Then someone on your team needs to focus it when a form fails validation. They pass a ref, call ref.current.focus(), and get an error, because the ref never reached the input. Your component swallowed it.

For years the answer was forwardRef, a wrapper that let function components receive a ref and hand it to a child. React 19 changed that: ref is now a regular prop for function components, and forwardRef is on its way out. Its partner, useImperativeHandle, is still very much alive and is the tool for exposing a small, deliberate API instead of a raw DOM node.

This post covers how refs flow through components, how forwardRef worked and how to migrate off it, the React 19 ref-as-a-prop pattern with TypeScript, and when useImperativeHandle is the right call (and when it isn't).

Why Refs Don't Pass Through Like Props

A ref is a container whose current property React fills with a DOM node (or some other value) after commit. You create one with useRef and attach it with the ref attribute:

import { useRef } from "react";

export function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={inputRef} placeholder="Search" />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
    </>
  );
}

That works because input is a host element. React knows to put the DOM node in inputRef.current. When you attach a ref to your own component instead, React has to decide what "the node" is. Before React 19, function components simply had no instance, so ref was stripped from props and React logged a warning. That's the gap forwardRef filled.

If you want a refresher on refs themselves, including using them for values that aren't DOM nodes, see mastering useRef beyond DOM elements.

How forwardRef Worked

forwardRef takes a render function with a second parameter, the ref, and returns a component that accepts ref:

import { forwardRef, type InputHTMLAttributes } from "react";

type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
  label: string;
};

export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  function TextField({ label, ...rest }, ref) {
    return (
      <label>
        {label}
        <input ref={ref} {...rest} />
      </label>
    );
  }
);

Notice the generic order: the ref type first, the props type second, which is the reverse of what most people guess. You'll see this pattern in nearly every component library written before 2025, and it still works in React 19. It's just no longer necessary.

React 19: ref Is a Prop

Starting with React 19, function components receive ref in their props like any other value. The same component becomes:

import type { ComponentProps } from "react";

type TextFieldProps = ComponentProps<"input"> & {
  label: string;
};

export function TextField({ label, ref, ...rest }: TextFieldProps) {
  return (
    <label>
      {label}
      <input ref={ref} {...rest} />
    </label>
  );
}

ComponentProps<"input"> already includes ref with the correct type in the React 19 type definitions, so there's nothing extra to declare. If you define props by hand, add it explicitly:

import type { Ref } from "react";

type ButtonProps = {
  children: React.ReactNode;
  onClick?: () => void;
  ref?: Ref<HTMLButtonElement>;
};

export function Button({ children, onClick, ref }: ButtonProps) {
  return (
    <button ref={ref} onClick={onClick}>
      {children}
    </button>
  );
}

Use Ref rather than RefObject for the prop type. Ref covers both ref objects and callback refs, so callers can pass either.

The consumer side doesn't change at all:

import { useRef } from "react";
import { TextField } from "./TextField";

export function LoginForm() {
  const emailRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const email = emailRef.current?.value ?? "";
    if (!email.includes("@")) {
      emailRef.current?.focus();
      return;
    }
    // submit...
  }

  return (
    <form onSubmit={handleSubmit}>
      <TextField label="Email" name="email" ref={emailRef} />
      <button type="submit">Log in</button>
    </form>
  );
}

Migrating Away From forwardRef

The React team has said forwardRef will be deprecated in a future release. Migration is mechanical: unwrap the function, move ref from the second parameter into the props destructure, and update the types. There's an official codemod that does most of it:

npx codemod@latest react/19/remove-forward-ref

Run it on a branch, review the diff, and check components that use displayName or are wrapped in memo, since those sometimes need manual cleanup. The broader picture of React 19 changes is in upgrading to React 19.

One thing to watch for: class components still don't receive ref as a prop. A ref on a class component points to the class instance, as it always has.

Callback Refs and Cleanup

React 19 also lets callback refs return a cleanup function, similar to effects. That's useful when a ref needs to set up something that must be torn down, like an observer:

import { useState } from "react";

export function MeasuredBox({ children }: { children: React.ReactNode }) {
  const [width, setWidth] = useState(0);

  return (
    <div
      ref={(node) => {
        if (!node) return;
        const observer = new ResizeObserver(([entry]) => {
          setWidth(entry.contentRect.width);
        });
        observer.observe(node);
        return () => observer.disconnect();
      }}
    >
      <small>{Math.round(width)}px wide</small>
      {children}
    </div>
  );
}

When a callback ref returns a function, React calls that function on unmount instead of calling the ref with null. Because an inline arrow is a new function each render, React will detach and reattach it on every render. For cheap work like this that's fine, but wrap it in useCallback if setup is expensive.

Merging Refs

A common real-world problem: your component needs its own ref to the DOM node (to measure it, say) and also needs to forward the caller's ref. You can't attach two refs to one element directly, so merge them with a callback ref:

import type { Ref, RefCallback } from "react";

export function mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {
  return (node) => {
    for (const ref of refs) {
      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    }
  };
}
import { useEffect, useRef, type ComponentProps } from "react";
import { mergeRefs } from "./mergeRefs";

export function AutoGrowTextarea({ ref, ...rest }: ComponentProps<"textarea">) {
  const localRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    const el = localRef.current;
    if (!el) return;
    const resize = () => {
      el.style.height = "auto";
      el.style.height = `${el.scrollHeight}px`;
    };
    resize();
    el.addEventListener("input", resize);
    return () => el.removeEventListener("input", resize);
  }, []);

  return <textarea ref={mergeRefs(localRef, ref)} {...rest} />;
}

ref.current = node compiles because Ref<T> includes RefObject<T | null>, whose current is writable in React 19 types. This helper calls each ref with the node but doesn't propagate cleanup returns. That's enough for most cases. If any caller relies on cleanup-returning callback refs, collect their return values and call them from a returned cleanup function.

What useImperativeHandle Does

Sometimes giving the parent the raw DOM node is too much. Maybe your component has several internal elements, or the parent should only be able to call play() and pause() without touching styles or attributes. useImperativeHandle lets you replace what the parent's ref receives with an object you define.

useImperativeHandle(ref, () => handleObject, dependencies?);
  • ref is the ref your component received as a prop.
  • The second argument is a function that returns the value to expose.
  • The optional dependency array controls when React recreates that value, just like useMemo.

A Video Player With a Small API

import { useImperativeHandle, useRef, type Ref } from "react";

export type VideoHandle = {
  play: () => void;
  pause: () => void;
  seek: (seconds: number) => void;
};

type VideoPlayerProps = {
  src: string;
  ref?: Ref<VideoHandle>;
};

export function VideoPlayer({ src, ref }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useImperativeHandle(
    ref,
    () => ({
      play() {
        void videoRef.current?.play();
      },
      pause() {
        videoRef.current?.pause();
      },
      seek(seconds) {
        if (videoRef.current) videoRef.current.currentTime = seconds;
      },
    }),
    []
  );

  return <video ref={videoRef} src={src} controls={false} width={640} />;
}

The parent works with the handle type, not with HTMLVideoElement:

import { useRef } from "react";
import { VideoPlayer, type VideoHandle } from "./VideoPlayer";

export function Lesson() {
  const playerRef = useRef<VideoHandle>(null);

  return (
    <div>
      <VideoPlayer src="/lesson-1.mp4" ref={playerRef} />
      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.pause()}>Pause</button>
      <button onClick={() => playerRef.current?.seek(0)}>Restart</button>
    </div>
  );
}

This is a cleaner contract than handing out the whole video element. You can later swap video for a third-party player and keep the same three methods, and no parent code changes.

video.play() returns a promise that rejects if autoplay is blocked. The void here ignores it for brevity. In production, catch the rejection and show a play button.

Exposing Focus and Validation on a Form Field

Another good use is a field that exposes a couple of behaviors from multiple internal elements:

import { useId, useImperativeHandle, useRef, useState, type Ref } from "react";

export type PhoneFieldHandle = {
  focus: () => void;
  validate: () => boolean;
};

type PhoneFieldProps = {
  label: string;
  ref?: Ref<PhoneFieldHandle>;
};

export function PhoneField({ label, ref }: PhoneFieldProps) {
  const id = useId();
  const codeRef = useRef<HTMLSelectElement>(null);
  const numberRef = useRef<HTMLInputElement>(null);
  const [error, setError] = useState<string | null>(null);

  useImperativeHandle(ref, () => ({
    focus() {
      numberRef.current?.focus();
    },
    validate() {
      const value = numberRef.current?.value ?? "";
      const ok = /^\d{7,15}$/.test(value.replace(/\s/g, ""));
      setError(ok ? null : "Enter a valid phone number");
      return ok;
    },
  }));

  return (
    <fieldset>
      <legend>{label}</legend>
      <select ref={codeRef} aria-label="Country code" defaultValue="+1">
        <option value="+1">+1</option>
        <option value="+44">+44</option>
        <option value="+880">+880</option>
      </select>
      <input
        id={id}
        ref={numberRef}
        inputMode="tel"
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-error` : undefined}
      />
      {error && <p id={`${id}-error`}>{error}</p>}
    </fieldset>
  );
}

Without a dependency array, the handle is recreated on every render. That's harmless here and avoids stale closures, since setError is stable anyway.

When Not to Use useImperativeHandle

Imperative handles are an escape hatch. They're the right tool for things that are naturally commands: focus, scroll, play, pause, select text, reset an animation. They're the wrong tool for anything that can be expressed as props and state.

Compare these two approaches to opening a dialog:

// Imperative: parent calls dialogRef.current.open()
<ConfirmDialog ref={dialogRef} />

// Declarative: parent owns the state
<ConfirmDialog open={isOpen} onOpenChange={setIsOpen} />

The declarative version is easier to reason about. The parent always knows whether the dialog is open, it can render other UI based on that, and it works with tools like React DevTools and time-travel debugging. The imperative version hides state inside the child, so the parent has to track it separately if it cares.

A good rule: if the parent would need to ask "what state is the child in?", use props. If the parent just needs to trigger a one-off action, an imperative handle is fine. The same reasoning appears in controlled vs uncontrolled components, where refs to inputs are the uncontrolled option.

Common Mistakes With forwardRef and useImperativeHandle

  • Reading ref.current during render. Refs are filled after commit. Read them in event handlers and effects, not in the component body.
  • Typing the ref prop as RefObject. That rejects callback refs. Use Ref<T> for props.
  • Swapping forwardRef generics. forwardRef<RefType, PropsType> puts the ref type first. If you're still on forwardRef, this mismatch causes confusing errors.
  • Exposing state through a handle. A getValue() method that the parent polls is a sign that the value should be lifted into the parent as state.
  • Forgetting the dependency array matters. If your handle closes over props and you pass [], the handle sees stale props. Either list the dependencies or omit the array.
  • Spreading props without passing ref through. In React 19, ref is in props. If you destructure some props and spread the rest onto a child, ref goes along with the spread. That's usually right, but double-check when you also attach your own ref, so one doesn't overwrite the other.
  • Using a handle where a callback prop would do. If the child needs to tell the parent something, call a prop like onComplete. Refs flow down, not up.

Frequently Asked Questions (FAQ) About forwardRef and useImperativeHandle

Not yet, but it's no longer needed. In React 19, function components receive ref as a normal prop, and the React team has said forwardRef will be deprecated in a future version. Existing code keeps working, and an official codemod can migrate it for you.

Yes, when you want the parent's ref to receive a custom object instead of a DOM node. React 19 only changes how the ref reaches your component. What the ref points to is still controlled by where you attach it or by useImperativeHandle.

Use Ref<T> from React, where T is the DOM element or handle type, and make it optional. If your props extend ComponentProps for a host element like input, the ref prop is already included with the correct type.

Not directly, because an element accepts one ref. Merge them with a small callback ref that assigns the node to each ref, or expose a handle with useImperativeHandle that uses your local ref internally.

The element might be conditionally rendered and not mounted yet, or the child component isn't attaching the ref to anything. Check that the ref is passed all the way down to a host element or into useImperativeHandle, and that the element actually renders.

Yes. In React 19 a memoized function component receives ref in its props like any other prop, so you don't need to combine memo with forwardRef anymore.

Conclusion

Refs give parents a direct line to a DOM node or a child's API. forwardRef was the old way to pass that line through your own components. In React 19, ref is just a prop, so you destructure it, type it as Ref<T>, and attach it where it belongs. useImperativeHandle sits on top of that and lets you replace the raw node with a small, intentional set of methods.

Next steps: run the remove-forward-ref codemod on one package of your design system and review the diff, add a mergeRefs helper if you have components that need both local and forwarded refs, and audit any existing imperative handles. Keep the ones that expose real commands like focus or play, and turn the ones that expose state into props.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading