useRef & forwardRef

Difficulty: Intermediate

Question

What is useRef and how does it differ from state? Explain forwardRef and useImperativeHandle.

Answer

useRef is one of those hooks that seems simple but is actually incredibly versatile once you understand its nature. Let's peel back the layers.

At its core, useRef creates a container - a plain JavaScript object with a single property called current. That's literally it: { current: initialValue }. What makes this container special is two things. First, it persists across renders. Unlike a regular variable declared inside your component function (which gets recreated from scratch on every render), the ref object survives. The same object, the same reference, render after render. Second, and this is the crucial distinction from state: changing ref.current does NOT trigger a re-render. You can mutate it a thousand times, and React won't bat an eye. No re-render, no reconciliation, nothing.

This makes refs perfect for two broad categories of use cases.

Category one: direct DOM access. When you attach a ref to a JSX element via the ref attribute (<input ref={myRef} />), React sets myRef.current to the actual DOM node after the component mounts. Now you can call imperative DOM methods - myRef.current.focus(), myRef.current.scrollIntoView(), myRef.current.getBoundingClientRect() for measurements. This is React's escape hatch for when the declarative model isn't enough. Sometimes you genuinely need to imperatively tell the browser to focus an input, scroll to a position, or measure an element's size. Refs let you do that.

Category two: persistent mutable values. Any value that needs to survive across renders but shouldn't cause a re-render when it changes is a ref candidate. Timer and interval IDs are the classic example - you create an interval in a useEffect, store the ID in a ref, and clear it in the cleanup function. Previous values are another common one - you can store the previous value of a prop or state variable by updating a ref in a useEffect. Render counters, flags for whether the component has mounted, abort controllers, WebSocket connections - all perfect ref use cases.

A common mistake is confusing when to use refs versus state. Here's the heuristic: if the value affects what appears on screen (the rendered output), use state. If the value is needed for logic but doesn't need to be displayed and shouldn't trigger re-renders, use a ref. An input's typed text that you display? State. A timer ID that you need to clear later? Ref. A boolean tracking whether data has been fetched? Depends - if you show a loading spinner, it's state. If you just need to prevent a double-fetch, it's a ref.

Now let's talk about forwardRef, because this is where things get more advanced.

Here's the problem forwardRef solves. You've built a nice custom Input component, and from a parent component, you want to get a ref to the actual <input> DOM element inside it - maybe to focus it programmatically. So you try <CustomInput ref={inputRef} />. But this doesn't work. Function components don't have "instances" the way class components do, so ref doesn't automatically attach to anything. React would either ignore the ref or give you a warning.

forwardRef wraps your function component and provides the ref as a second argument (after props). Inside the component, you attach that forwarded ref to whatever DOM element the parent should have access to. Now the parent creates a ref, passes it to your component, and your component threads it through to the actual DOM node. The parent can then call .focus() or whatever it needs.

This pattern is essential for building reusable component libraries. Any low-level component that wraps a native HTML element (inputs, buttons, textareas, etc.) should probably support ref forwarding, because consumers of your component library will inevitably need direct DOM access for accessibility, focus management, or integration with third-party code.

Note for React 19: forwardRef is being deprecated in favor of just passing ref as a regular prop. You'll be able to write function Input({ ref, ...props }) directly, no wrapper needed. This is a nice simplification, but for now, most codebases still use forwardRef.

useImperativeHandle takes this a step further. Instead of exposing the raw DOM node to the parent (which gives the parent access to every single DOM method and property - a wide open API), useImperativeHandle lets you customize exactly what the parent sees through the ref.

Imagine you're building a video player component. The parent doesn't need access to the raw <video> DOM element with its 50+ properties and methods. It just needs play(), pause(), and maybe restart(). With useImperativeHandle, you define an object with exactly those methods, and that's what the parent gets through the ref. This is good component design - you're exposing a controlled, stable API instead of leaking implementation details.

useImperativeHandle takes three arguments: the forwarded ref, a function that returns the object you want to expose, and an optional dependency array (so the exposed object can update if dependencies change). It's used together with forwardRef.

Some important caveats about refs:

Don't read or write ref.current during rendering (the body of your component function, outside of useEffect or event handlers). In concurrent mode, React might render your component multiple times before committing, and reading/writing refs during render can lead to inconsistent behavior. Refs should be accessed in useEffect, useLayoutEffect, or event handlers.

Don't overuse refs. If you find yourself using refs to track state that affects rendering, you're fighting against React's model. The declarative approach (state + props driving the UI) is React's strength, and refs are the escape hatch for when declarative isn't enough. Treat them like goto statements - powerful when genuinely needed, a code smell when overused.

The difference between useRef and createRef (an older API) is worth knowing: useRef returns the same ref object on every render. createRef creates a new ref object on every render. In function components, always use useRef. createRef was designed for class components where it's called in the constructor (once), not in the render method.

Code examples

useRef for DOM Access and Mutable Values

import { useRef, useState, useEffect } from 'react';

function SearchInput() {
  const inputRef = useRef(null);
  const renderCount = useRef(0);
  const prevQuery = useRef('');
  const [query, setQuery] = useState('');

  // Track render count without causing re-renders
  renderCount.current += 1;

  // Store previous value
  useEffect(() => {
    prevQuery.current = query;
  }, [query]);

  // Focus input on mount
  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return (
    <div>
      <input
        ref={inputRef}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <p>Current: {query}</p>
      <p>Previous: {prevQuery.current}</p>
      <p>Renders: {renderCount.current}</p>
    </div>
  );
}

Refs persist across renders like state but do not trigger re-renders when changed. This makes them perfect for tracking values that should not affect the UI, like render counts or previous state.

forwardRef Pattern

import { forwardRef, useRef } from 'react';

// Without forwardRef, you can't pass ref to this component
// const Input = ({ label, ...props }) => (
//   <input {...props} />  // No way to access this input's DOM node
// );

// With forwardRef - ref is passed as second argument
const FancyInput = forwardRef(function FancyInput(
  { label, error, ...props },
  ref
) {
  return (
    <div className="field">
      <label>{label}</label>
      <input ref={ref} {...props} />
      {error && <span className="error">{error}</span>}
    </div>
  );
});

// Parent can now access the input DOM element
function LoginForm() {
  const emailRef = useRef(null);
  const passwordRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    if (!emailRef.current.value) {
      emailRef.current.focus();
      return;
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <FancyInput ref={emailRef} label="Email" type="email" />
      <FancyInput ref={passwordRef} label="Password" type="password" />
      <button type="submit">Login</button>
    </form>
  );
}

forwardRef bridges the gap between parent refs and child DOM elements. The parent creates a ref, passes it to the child, and the child attaches it to the actual DOM node.

useImperativeHandle for Custom API

import { forwardRef, useRef, useImperativeHandle, useState } from 'react';

const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
  const videoRef = useRef(null);
  const [isPlaying, setIsPlaying] = useState(false);

  // Expose a limited API instead of the full DOM node
  useImperativeHandle(ref, () => ({
    play() {
      videoRef.current.play();
      setIsPlaying(true);
    },
    pause() {
      videoRef.current.pause();
      setIsPlaying(false);
    },
    restart() {
      videoRef.current.currentTime = 0;
      videoRef.current.play();
      setIsPlaying(true);
    },
    get isPlaying() {
      return isPlaying;
    }
  }), [isPlaying]);

  return <video ref={videoRef} src={src} />;
});

// Parent uses the custom API
function App() {
  const playerRef = useRef(null);
  return (
    <div>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current.play()}>Play</button>
      <button onClick={() => playerRef.current.pause()}>Pause</button>
      <button onClick={() => playerRef.current.restart()}>Restart</button>
    </div>
  );
}

useImperativeHandle lets you control exactly what methods and properties the parent can access through the ref. This encapsulates internal implementation details.

Key points

Concepts covered

useRef, forwardRef, DOM Access, Mutable Refs, useImperativeHandle