DevLift
Back to Blog

React: Exposing Imperative APIs from a Component — ref-as-prop and useImperativeHandle

Stop using boolean props as imperative commands. In React 19 ref is just a prop — accept it, or use useImperativeHandle to expose a clean typed API for focus, reset and validate.

Admin
August 2, 20267 min read2 views

React: Exposing Imperative APIs from a Component — ref-as-prop and useImperativeHandle

You're building a multi-step form. Step one has a custom <PhoneInput> component you built — it wraps an <input> with country-code parsing and masking logic. When the user clicks "Continue" from the parent, you want to programmatically focus that input if it's still empty. You pass a ref down... and nothing happens. phoneRef.current is null.

So you add a useEffect that watches a boolean prop and calls focus inside the child. Then you need another prop for "scroll into view on error". Then another for "reset the field". Three props later, your clean component is a mess of coordination flags, and the parent is calling setFocusPhone(true) instead of just calling phoneRef.current.focus().

That's the problem this pattern exists to solve.

Why the Ref Was Null

React's ref attribute on a DOM element gives you direct access to the underlying node:

const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />
// inputRef.current is the <input> DOM node

On a component, ref is just another prop — and like any prop, it does nothing unless the component actually uses it:

// No ref in the signature, so the ref has nowhere to land
function PhoneInput({ placeholder }: { placeholder?: string }) {
  return <input placeholder={placeholder} />;
}
 
function CheckoutForm() {
  const phoneRef = useRef<HTMLInputElement>(null);
  // <PhoneInput ref={phoneRef} />
  //   ^ phoneRef.current stays null. TypeScript catches it too:
  //     Property 'ref' does not exist on type '{ placeholder?: string }'.
  return <PhoneInput placeholder="(555) 000-0000" />;
}

React can't guess what ref should point to inside PhoneInput. The outer <div>? The inner <input>? A custom handle? So it hands ref to the component as a prop and lets the component decide.

⚠️

On React 18 and earlier this was different: function components could not receive ref at all, and React logged "Function components cannot be given refs". That is why so much existing code is wrapped in forwardRef. In React 19 that warning is gone — passing a ref to a component that ignores it is silent, and ref.current simply stays null. Verified against React 19.2.3: no console output, ref.current === null.

The Bad Pattern: Coordination via Props

The instinct, once the ref comes back null, is to punt to props and callbacks:

// ❌ Don't do this — props-as-imperative-commands
interface PhoneInputProps {
  shouldFocus: boolean;       // "trigger focus right now"
  shouldReset: boolean;       // "trigger reset right now"
  onFocusDone: () => void;    // "tell parent we focused"
  onResetDone: () => void;    // "tell parent we reset"
}
 
function PhoneInput({ shouldFocus, shouldReset, onFocusDone, onResetDone }: PhoneInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);
 
  useEffect(() => {
    if (shouldFocus) {
      inputRef.current?.focus();
      onFocusDone(); // reset the flag in parent
    }
  }, [shouldFocus]);
 
  useEffect(() => {
    if (shouldReset) {
      // reset logic...
      onResetDone();
    }
  }, [shouldReset]);
 
  return <input ref={inputRef} />;
}

This pattern is a footgun. Boolean props that represent imperative commands (focus now, reset now) create race conditions, require the parent to manage "done" state, and are almost impossible to call twice in a row cleanly. If shouldFocus is already true and you want to focus again, you have to set it to false first, wait a tick, then set it back to true. It's a hack.

The Pattern: Accept ref as a Prop

Declare ref in your props and put it where you want it. That's the whole mechanism in React 19:

import { useRef, type Ref } from 'react';
 
interface PhoneInputProps {
  placeholder?: string;
  ref?: Ref<HTMLInputElement>;
}
 
function PhoneInput({ placeholder, ref }: PhoneInputProps) {
  return (
    <div className="phone-input-wrapper">
      <span className="country-code">+1</span>
      <input
        ref={ref}   // hand the parent's ref to the actual DOM node
        type="tel"
        placeholder={placeholder}
      />
    </div>
  );
}

Pre-19 code does the same thing with forwardRef(function PhoneInput(props, ref) { ... }), which pulls ref out into a second argument. forwardRef still works in React 19.2 and emits no deprecation warning, but React has said it will be deprecated and removed in a future release. For new components, take ref as a prop.

Now the parent gets direct access to the <input> DOM node:

function CheckoutForm() {
  const phoneRef = useRef<HTMLInputElement>(null);
 
  const handleContinue = () => {
    if (!phoneRef.current?.value) {
      phoneRef.current?.focus(); // clean, synchronous, no flags
    }
  };
 
  return (
    <form>
      <PhoneInput ref={phoneRef} placeholder="(555) 000-0000" />
      <button type="button" onClick={handleContinue}>Continue</button>
    </form>
  );
}

No coordination props. No callbacks. No useEffect chains. The parent asks for focus and it happens.

When Forwarding the DOM Node Isn't Enough: useImperativeHandle

Forwarding the raw <input> node works great for focus and blur — those are standard DOM APIs. But what if you want the parent to trigger something that only exists inside the component? Like reset() (which needs to clear your internal mask state) or validate() (which checks phone number format and sets an error)?

Exposing the raw DOM node doesn't help there. And you don't want to expose it anyway — that's a leaky abstraction. The parent shouldn't need to know there's a specific <input> inside PhoneInput.

This is where useImperativeHandle comes in. It lets you replace what ref.current points to with a custom object you define:

import { useRef, useImperativeHandle, useState, type Ref } from 'react';
 
interface PhoneInputHandle {
  focus: () => void;
  reset: () => void;
  validate: () => boolean;
}
 
interface PhoneInputProps {
  placeholder?: string;
  ref?: Ref<PhoneInputHandle>;
}
 
function PhoneInput({ placeholder, ref }: PhoneInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [value, setValue] = useState('');
  const [error, setError] = useState('');
 
  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current?.focus();
    },
    reset() {
      setValue('');
      setError('');
      inputRef.current?.blur();
    },
    validate() {
      const isValid = /^\d{10}$/.test(value.replace(/\D/g, ''));
      if (!isValid) setError('Please enter a valid 10-digit number');
      return isValid;
    },
  }), [value]); // re-create handle when value changes
 
  return (
    <div>
      <input
        ref={inputRef}
        type="tel"
        value={value}
        placeholder={placeholder}
        onChange={(e) => setValue(e.target.value)}
      />
      {error && <span className="error">{error}</span>}
    </div>
  );
}

Now the parent gets a clean, intentional API:

function CheckoutForm() {
  const phoneRef = useRef<PhoneInputHandle>(null);
 
  const handleSubmit = () => {
    if (!phoneRef.current?.validate()) {
      phoneRef.current?.focus();
      return; // stop submission
    }
    // proceed with form submission...
  };
 
  const handleReset = () => {
    phoneRef.current?.reset();
  };
 
  return (
    <form>
      <PhoneInput ref={phoneRef} placeholder="(555) 000-0000" />
      <button type="button" onClick={handleReset}>Start Over</button>
      <button type="button" onClick={handleSubmit}>Submit</button>
    </form>
  );
}

The parent doesn't know the phone number is stored in a state variable called value. It doesn't know there's an internal <input> node. All it sees is focus(), reset(), and validate(). That's the contract.

How the Data Flows

Rendering diagram...

The key insight: there are two refs in play. The ref passed from the parent (which useImperativeHandle populates with your custom handle object) and the internal inputRef that PhoneInput owns for direct DOM access. The parent never sees the internal one.

Reading (and Migrating) forwardRef Code

Most React code you will meet predates 19, so you need to read the older shape fluently. forwardRef takes a render function whose second argument is the ref:

// The pre-19 equivalent of the component above
import { forwardRef, useRef, useImperativeHandle, useState } from 'react';
 
// forwardRef<RefType, Props>
const PhoneInput = forwardRef<PhoneInputHandle, { placeholder?: string }>(
  function PhoneInput({ placeholder }, ref) {
    const inputRef = useRef<HTMLInputElement>(null);
    const [value, setValue] = useState('');
 
    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      reset: () => setValue(''),
      validate: () => /^\d{10}$/.test(value.replace(/\D/g, '')),
    }), [value]);
 
    return <input ref={inputRef} type="tel" value={value} onChange={(e) => setValue(e.target.value)} />;
  }
);

Migrating is mechanical: delete the wrapper, move ref into the props type, and delete the second parameter. Call sites do not change at all — <PhoneInput ref={phoneRef} /> works either way. There is no rush; both forms behave identically in 19.2.

One thing that does change: memo. Before 19 you had to nest them in a specific order, memo(forwardRef(...)). Now memo over a plain component that takes ref as a prop both memoises and forwards the ref, with no wrapper sandwich.

Real-World Use Cases

Multi-Section Settings Forms

A settings page with independent sections — each rendered as its own component. The parent needs a single "Save All" button:

interface SectionHandle {
  submit: () => Promise<boolean>;
}
 
function ProfileSection({ ref }: { ref?: Ref<SectionHandle> }) {
  const [name, setName] = useState('');
 
  useImperativeHandle(ref, () => ({
    async submit() {
      const result = await saveProfile({ name });
      return result.ok;
    },
  }));
 
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
 
function NotificationsSection({ ref }: { ref?: Ref<SectionHandle> }) {
  const [emailEnabled, setEmailEnabled] = useState(true);
 
  useImperativeHandle(ref, () => ({
    async submit() {
      const result = await saveNotifications({ emailEnabled });
      return result.ok;
    },
  }));
 
  return <input type="checkbox" checked={emailEnabled} onChange={(e) => setEmailEnabled(e.target.checked)} />;
}
 
function SettingsPage() {
  const profileRef = useRef<SectionHandle>(null);
  const notificationsRef = useRef<SectionHandle>(null);
 
  const saveAll = async () => {
    const results = await Promise.all([
      profileRef.current?.submit(),
      notificationsRef.current?.submit(),
    ]);
    if (results.every(Boolean)) showToast('Saved!');
  };
 
  return (
    <>
      <ProfileSection ref={profileRef} />
      <NotificationsSection ref={notificationsRef} />
      <button onClick={saveAll}>Save All</button>
    </>
  );
}

Each section owns its own state and submit logic. The parent orchestrates without knowing the internals.

Canvas / Chart Components

Chart libraries often need imperative control — export as PNG, reset zoom, highlight a data point:

interface ChartHandle {
  exportPng: () => string;  // returns data URL
  resetZoom: () => void;
  highlightPoint: (index: number) => void;
}
 
function LineChart({ data, ref }: { data: number[]; ref?: Ref<ChartHandle> }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
 
  useImperativeHandle(ref, () => ({
    exportPng() {
      return canvasRef.current?.toDataURL('image/png') ?? '';
    },
    resetZoom() {
      // reset internal zoom state...
    },
    highlightPoint(index) {
      // draw highlight at data[index]...
    },
  }));
 
  useEffect(() => {
    // draw chart on canvasRef...
  }, [data]);
 
  return <canvas ref={canvasRef} width={600} height={300} />;
}

Dialog / Drawer Components

Modals that expose open() and close() can be convenient when a modal is used in multiple spots and you don't want to lift the isOpen state up:

interface DialogHandle {
  open: () => void;
  close: () => void;
}
 
function ConfirmDialog({ onConfirm, ref }: { onConfirm: () => void; ref?: Ref<DialogHandle> }) {
  const [isOpen, setIsOpen] = useState(false);
 
  useImperativeHandle(ref, () => ({
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
  }));
 
  if (!isOpen) return null;
  return (
    <div role="dialog">
      <p>Are you sure?</p>
      <button onClick={onConfirm}>Confirm</button>
      <button onClick={() => setIsOpen(false)}>Cancel</button>
    </div>
  );
}
 
// Usage
function DeleteButton({ itemId }: { itemId: string }) {
  const dialogRef = useRef<DialogHandle>(null);
 
  return (
    <>
      <button onClick={() => dialogRef.current?.open()}>Delete</button>
      <ConfirmDialog
        ref={dialogRef}
        onConfirm={() => deleteItem(itemId)}
      />
    </>
  );
}
⚠️

The dialog-with-imperative-open pattern works, but it puts component visibility state inside the dialog component — which means you can't drive it from a URL or global state. For routing-based modals or toast systems, keep the open state outside and use props. Reach for the imperative handle only when the open/close state is genuinely local.

TypeScript Patterns

A few things worth knowing when using these with TypeScript:

Export the handle type so callers can type their refs:

// components/PhoneInput.tsx
export interface PhoneInputHandle {
  focus: () => void;
  reset: () => void;
  validate: () => boolean;
}
 
export function PhoneInput({ placeholder, ref }: PhoneInputProps) {
  /* ... */
}
// Usage elsewhere
import type { PhoneInputHandle } from '@/components/PhoneInput';
 
const ref = useRef<PhoneInputHandle>(null);

Null-safe calls. ref.current is PhoneInputHandle | null before the component mounts. Always use optional chaining:

phoneRef.current?.focus();   // ✓
phoneRef.current.focus();    // ✗ — throws if called before mount

Generic components. If your component is generic (like a <List<T>> or <Select<T>>), forwardRef and generics don't compose cleanly. The workaround is to cast:

function SelectInner<T>({ options, ref }: SelectProps<T> & { ref?: Ref<SelectHandle> }) {
  // ...imperative handle, keyboard nav, etc.
  return <select />;
}
 
// Cast to preserve generic type inference
export const Select = SelectInner as <T>(
  props: SelectProps<T> & { ref?: Ref<SelectHandle> }
) => React.JSX.Element;

Note React.JSX.Element, not a bare JSX.Element: @types/react 19 removed the global JSX namespace, so the unqualified form no longer compiles. The cast itself is still needed for generic components — that part forwardRef never solved either.

When NOT to Use This Pattern

When state + callbacks are clearer. If a parent just needs to know when something happened (not tell the child to do something), use callbacks. onFocus, onChange, and onValidationError are React-idiomatic and don't require refs.

When you're only forwarding to a DOM element. If all you need is for the parent to call focus(), blur(), or scrollIntoView() — all standard DOM methods — then just put the incoming ref on the element. No useImperativeHandle, no handle type, no extra indirection.

When the component is controlled. Controlled components drive their behavior through props: value, isOpen, isSelected. If your component is already fully controlled, you don't need imperative handles — the parent already has full control through props.

When you're tempted to expose too much. If your handle object is growing — submit(), reset(), validate(), clearErrors(), scrollToFirstError(), setFieldValue() — that's a sign the parent has too much knowledge about the child's internals. Consider whether some of this behavior belongs in the child itself, triggered by props, or whether you're essentially rebuilding a form library from scratch.

A good rule of thumb: expose only the methods that the parent genuinely needs to call in response to user actions that happen outside the component. If the action happens inside the component (user types, user clicks submit), handle it inside. If it happens outside (parent form submits, parent clears all fields), expose a method for it.

The Mental Model

There are two separate problems here, and it's worth keeping them apart.

Ref forwarding answers "how do I let a ref attach to something inside this component?" In React 19 that's a one-liner: accept ref and put it on the node. (forwardRef is the pre-19 spelling of the same idea.)

useImperativeHandle answers something different: "I don't want to expose the raw DOM node — I want to expose a custom, curated API that hides the internals." It's an API design tool, and it is the part that still earns its keep.

You use them together when a parent component needs to imperatively trigger behavior that lives inside a child, and you want a clean interface rather than a bag of coordination flags. The result is components with explicit contracts — the handle type — instead of implicit coupling through prop patterns.

The moment you find yourself passing boolean props that represent "do this now" commands, or threading a flag through a useEffect chain to trigger a side effect in a child, step back. A ref with a custom handle is almost certainly the right answer.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read