React|Built-in Hook Mastery

The Retirement of forwardRef and useImperativeHandle: Now, ref is Just a Prop

10
The Retirement of forwardRef and useImperativeHandle: Now, ref is Just a Prop

Chris is building a signup form.

He wants to implement a feature where, if a user clicks "Check for Duplicates" and the email is already taken, the email input field (CustomInput) turns red and automatically receives focus.

Since the parent component needs access to the child's input, Chris decided to pass a ref, just as he learned.

typescript
// Parent.tsx
function Parent() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    inputRef.current?.focus(); // 💥 Error: current is undefined?
  };

  return <CustomInput ref={inputRef} />;
}

// CustomInput.tsx
// ❌ Traditional approach: ref is not passed as a prop.
function CustomInput(props) {
  return <input ref={props.ref} />; // props.ref is undefined.
}

A red warning appears in the console: "Function components cannot be given refs..."

This is because in React, ref (along with key) is a reserved word treated specially and is not passed as a standard prop.

To solve this, we historically had to use a complex Higher-Order Component (HOC) called forwardRef. However, with the arrival of React 19, this long-standing inconvenience is finally fading into history.

1. A Relic of the Past: The Inconvenience of forwardRef

Until React 18, if a child component wanted to receive a ref, it absolutely had to be wrapped in forwardRef.

typescript
// ⚠️ React 18 and below: forwardRef is mandatory
import { forwardRef } from 'react';

const CustomInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />;
});

// There was also the hassle of setting displayName for debugging because the component name got lost.
CustomInput.displayName = 'CustomInput';

This method was uncomfortable for several reasons:

  • Reduced Readability: Wrapping the function adds an extra layer of nesting (Indent).
  • Generic Type Hell: Matching the order and types of generics for forwardRef in TypeScript was quite a headache.
  • Mental Model Mismatch: "A component is just a function, so why does only ref get special treatment?"
  • 2. The React 19 Revolution: ref is Now Just a Prop

    The React 19 team resolved this inconsistency. ref is no longer a special reserved word. It has become just a part of props, like className or onClick.

    Now, Chris can delete forwardRef and pull ref directly from the function arguments.

    typescript
    // ✅ React 19: Just receive and use it.
    function CustomInput({ ref, ...props }: { ref: React.Ref<HTMLInputElement> }) {
      return <input ref={ref} {...props} />;
    }

    How beautiful! The wrapper shell around the component is gone, and the code has become intuitive. This aligns with the direction of "simplicity" that the React team is pursuing.

    3. useImperativeHandle: Showing Parents Only What You Want

    When you pass a ref, the parent gets the entire DOM node (HTMLInputElement) of the child.

    This means the parent can arbitrarily hide the child via input.style.display = 'none' or force-change its value. This is dangerous behavior that breaks encapsulation.

    "I want to expose only the focus() function to the parent and hide the rest."

    The hook used for this purpose is useImperativeHandle. It allows you to customize the content of the ref that the parent receives.

    How to Implement

    Using useImperativeHandle, instead of the DOM node, a custom object (Handle) that we create is stored in the parent's ref.current.

    typescript
    // Child Component
    import { useImperativeHandle, useRef } from 'react';
    
    // 1. Define the type of methods to expose to the parent
    export interface InputHandle {
      focus: () => void;
      shake: () => void; // Shake animation on error
    }
    
    function CustomInput({ ref, ...props }: { ref: React.Ref<InputHandle> }) {
      const internalRef = useRef<HTMLInputElement>(null);
    
      // 2. Connect (Handle) the object I made to the parent's ref.
      useImperativeHandle(ref, () => {
        return {
          // Expose only the focus method of the actual input DOM
          focus: () => internalRef.current?.focus(),
          
          // Add my own custom method
          shake: () => {
            internalRef.current?.classList.add('shake');
            setTimeout(() => internalRef.current?.classList.remove('shake'), 500);
          }
        };
      });
    
      return <input ref={internalRef} {...props} />;
    }

    Usage in Parent Component

    Now, the parent cannot touch the child's input tag itself. It can only use the focus and shake functions permitted by the child.

    typescript
    // Parent.tsx
    function Parent() {
      const inputRef = useRef<InputHandle>(null);
    
      const handleError = () => {
        // DOM manipulation is impossible, but defined functions are usable
        inputRef.current?.focus();
        inputRef.current?.shake();
      };
    
      return <CustomInput ref={inputRef} />;
    }

    This pattern is useful when creating video player components (exposing only play and pause) or delegating control of complex animations to the parent.

    4. When Should You Use This?

    The cardinal rule of React is "Data must flow unidirectionally (Parent -> Child)." Control via ref is an "Escape Hatch" that goes against this principle.

  • Recommended:
  • Not Recommended:
  • If it can be solved declaratively (props), it is better to avoid the imperative way (ref).

    Key Takeaways

  • React 19 Changes: forwardRef has retired. Now ref can be destructured directly from function arguments like a normal prop.
  • useImperativeHandle: When passing the entire DOM node to the parent feels risky, you can create a controlled API that exposes only specific functions.
  • Encapsulation: Using this hook allows you to hide the child component's internal implementation (DOM structure, etc.) while safely delegating only necessary controls to the parent.

  • Handling ref has become much cleaner. But React 19's innovation doesn't stop here.

    The restriction of having to declare hooks at the top level of a component every time you use useContext is also disappearing. What if you could read Context even inside a conditional statement?

    Continuing in: "use API: The Evolution of useContext and Handling Promises (Suspense)."

    🔗 References

  • React 19 Beta Blog Post
  • React Docs - Manipulating the DOM with Refs
  • React Docs - useImperativeHandle
  • Comments (0)

    0/1000 characters
    Loading comments...