Skip to content

useRef and Working with the DOM

Aman Kumar Singh8 min read
Part 6 of 50From the React & Next.js Complete Guide series
useRef and Working with the DOM — article by Aman Kumar Singh

In useMemo and useCallback, When They Help, I went through the cases where memoization actually earns its keep and the far more common cases where it just adds noise. useRef sits next to those two hooks in most people's mental model of "the performance hooks," but it solves a different problem entirely. It doesn't memoize anything. It gives you a place to put a value that survives across renders without ever triggering one.

This is part of the React & Next.js Complete Guide series, which I'm building as a practical companion to the Full Stack SaaS Masterclass. useRef tends to get introduced early as "the way you access the DOM in React," and that's true, but it undersells the hook. The DOM access case is really a special case of a more general one. React needs a mutable container that isn't part of the render output, and useRef is that container.

I want to cover both jobs useRef does, where they genuinely apply, and the specific ways refs get misused: treating them as a substitute for state, or reaching for direct DOM manipulation when the declarative version would have been simpler.

The two jobs useRef actually does

A useRef call returns an object shaped like { current: T }. That object is stable across the component's entire lifetime; React creates it once and hands you back the same reference on every render. What you put in .current is entirely up to you, and mutating it does not schedule a re-render.

That single mechanism supports two use cases that look unrelated on the surface:

// Job 1: a handle to a DOM node
const inputRef = useRef<HTMLInputElement>(null);

// Job 2: a mutable value that isn't part of the render output
const renderCountRef = useRef(0);

For the DOM case, React fills in .current for you: it points to the actual DOM node once it's mounted, and back to null once it unmounts. For the mutable-value case, you're managing .current yourself. Both cases share the same underlying property: writing to .current doesn't cause a re-render, and reading it always gives you the latest value, even inside closures created on an earlier render. That second property is what makes refs a real escape hatch from React's usual render-and-diff cycle, and also what makes them easy to misuse.

Reaching into the DOM when the declarative API can't do the job

React's whole model is that you describe what the UI should look like and let it handle the DOM diffing. Most of the time that's exactly what you want. useRef for DOM access exists for the remaining cases where you need something the declarative model doesn't expose: calling .focus(), reading .scrollHeight, measuring getBoundingClientRect(), or integrating a non-React library that expects a real element.

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

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="text" placeholder="Search..." />;
}

The pattern to notice: the ref only ever gets read inside an effect or an event handler, never during the render itself. Reading .current while rendering is unreliable because the DOM node might not exist yet on the first render, and even when it does, using it as a rendering input breaks the assumption that render is a pure function of props and state. If you find yourself wanting to read a ref's value to decide what JSX to return, that's usually a sign the value belongs in state instead of a ref.

Measuring an element is the case I reach for most often in dashboard UIs, things like a chart that needs its container width before it can lay out an SVG:

function ChartContainer({ children }: { children: (width: number) => React.ReactNode }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    const node = containerRef.current;
    if (!node) return;

    const observer = new ResizeObserver((entries) => {
      setWidth(entries[0].contentRect.width);
    });
    observer.observe(node);

    return () => observer.disconnect();
  }, []);

  return <div ref={containerRef}>{width > 0 && children(width)}</div>;
}

useLayoutEffect instead of useEffect matters here specifically because layout measurement should happen before the browser paints, so the user doesn't see a flash of the wrong width. The ref itself is just the handle; ResizeObserver and the effect are doing the actual work.

Storing mutable values that live outside the render cycle

The less obvious use of useRef is as a box for values that need to persist across renders but that shouldn't cause a re-render when they change. Interval and timeout IDs are the classic example:

function useDebouncedCallback<Args extends unknown[]>(
  callback: (...args: Args) => void,
  delayMs: number,
) {
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  return useCallback(
    (...args: Args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => callback(...args), delayMs);
    },
    [callback, delayMs],
  );
}

If timeoutRef were a useState value instead, every call to the debounced function would trigger a render just to update an ID nobody displays. That's wasted work. Worse, it would put the timeout ID through React's batching rules, adding delay and complexity to something that should be a plain synchronous assignment.

Another common pattern tracks a previous value for comparison:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

This works because the effect runs after the render commits, so during the current render, ref.current still holds whatever was written during the previous render's effect. It's a small hook. But the shape generalizes: store the last thing so you can diff against the current thing. That pattern shows up in animation transitions, analytics that need to log a value change, and form fields that need to detect whether a user actually edited something versus the value just being re-rendered with the same content.

Exposing an imperative API with forwardRef and useImperativeHandle

Occasionally a parent component genuinely needs to call a method on a child instead of only passing it props. A video player that needs an external "play" button, or a modal that a parent wants to close from a keyboard shortcut, are reasonable cases. forwardRef combined with useImperativeHandle lets a component control exactly what its ref exposes, instead of leaking the raw DOM node:

type VideoPlayerHandle = {
  play: () => void;
  pause: () => void;
};

const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
  function VideoPlayer({ src }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
    }));

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

The parent gets a play and pause method, not .currentTime, .volume, or any other property of the underlying <video> element. That's a deliberate API boundary, and it's the main reason to reach for useImperativeHandle instead of just forwarding the raw DOM ref: it lets the child component decide what's safe to call from the outside, the same way a class would expose public methods while keeping internals private.

I'd treat this as a pattern for genuine imperative needs, things a callback prop or state change can't express cleanly, rather than a default way to communicate between parent and child. Most parent-child communication in React is better served by props flowing down and callbacks flowing up. Reaching for an imperative handle by default tends to produce components that are harder to test, since you now need to simulate ref calls instead of just rendering with different props.

Production pitfalls with refs

A few mistakes with useRef show up often enough to call out directly.

Mutating a ref and expecting the UI to update is the most common one. Since writing to .current doesn't trigger a render, a value that needs to show up in the DOM must live in state, even if it feels wasteful to "duplicate" it. Refs are for values the render function doesn't need to know about.

Null-checking DOM refs is not optional, even with TypeScript. useRef<HTMLDivElement>(null) types .current as HTMLDivElement | null, and that's accurate: the ref is null before mount, after unmount, and briefly during the first render before the DOM commit happens. Every access in an effect or event handler should guard against that, as the examples above do with the optional chaining operator.

Callback refs deserve more use than they get. Instead of ref={someRef}, you can pass a function: ref={(node) => { /* ... */ }}. React calls that function with the node on mount and with null on unmount, which is useful when you need to run setup logic exactly when the node becomes available, rather than reading a ref inside an effect and hoping the timing lines up. The ResizeObserver example above could be rewritten as a callback ref to avoid the extra useLayoutEffect entirely.

Refs during server-side rendering need a mental adjustment. On the server, there's no DOM, so a ref's .current stays null through the entire server render. Any logic gated on a ref value must assume it runs client-side only, typically inside useEffect, which never runs during SSR. This is one of the reasons ref-based DOM logic reads cleanly in Next.js: effects are already the client-only escape hatch, and refs are meant to be read there anyway.

Key takeaways

  • `useRef` returns a stable object whose `.current` property can be mutated without causing a re-render, which is the property that makes it useful for both DOM handles and general mutable storage.
  • Read DOM refs inside effects or event handlers, never during render, since the node may not exist yet and using it as a render input breaks React's pure-function model.
  • Use refs for values the render function doesn't need to display, like timeout IDs or a previous value for comparison; if it needs to show up in the UI, it belongs in state.
  • `forwardRef` with `useImperativeHandle` lets a component expose a deliberate, narrow imperative API instead of leaking the raw DOM node to a parent.
  • Callback refs run exactly when a node mounts or unmounts, which is often a cleaner fit than reading an object ref inside an effect and hoping the timing works out.
  • On the server, ref values stay null through the entire render, so ref-dependent logic belongs in client-only code paths like effects.

Frequently asked questions

Does useRef cause a re-render when I update .current?

No. Writing to `.current` is a plain mutation and never schedules a render. That's the defining difference from `useState`, and it's exactly why refs are useful for values the UI doesn't need to reflect.

Why is my ref null on the first render?

React attaches the DOM node to `.current` after the render commits, not during render. If you read the ref synchronously in the function body, it will still be `null`. Move that read into a `useEffect` or an event handler, both of which run after the commit.

Should I use a ref instead of state to avoid extra renders?

Only if the value truly doesn't need to appear in the rendered output. If you move a value into a ref purely to dodge a re-render, but the UI still needs to reflect it, you've just introduced a bug where the display goes stale. Refs are for bookkeeping, not for hiding state changes from the render cycle.

What's the difference between a ref callback and an object ref?

An object ref, created with `useRef`, gets filled in by React after mount and read whenever you access `.current`. A callback ref is a function React calls directly with the node on mount and with `null` on unmount, which is useful when you need setup logic to run at that exact moment rather than in a separate effect.

Can I use useRef to store a value across renders in a class-based mental model?

Roughly, yes. A ref behaves like an instance variable in a class component: it persists for the component's lifetime and doesn't participate in the render cycle. The main difference is that a ref's identity is stable but you're responsible for initializing and updating its contents yourself.

Related articles

Avoiding Unnecessary Re-Renders — Aman Kumar Singh
Using React Context the Right Way — Aman Kumar Singh
useEffect Without the Footguns — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.