EmpireUI
Get Pro
← Blog7 min read#react#use-ref#hooks

useRef Patterns: Beyond the Basics — DOM, Intervals, Prev Values

useRef does a lot more than grab DOM nodes. Learn interval refs, previous value tracking, mutable stores, and forwardRef patterns that actually solve real problems.

Code editor showing React hooks on a dark background with syntax highlighting

useRef Is Not Just a DOM Handle

Honestly, most tutorials treat useRef like it's only good for grabbing a DOM node — scrolling to it, focusing it, measuring its width. That picture is wildly incomplete.

Under the hood, useRef returns a plain object: { current: T }. React never touches .current during renders. It doesn't schedule re-renders when you mutate it. That's the whole point. You get a stable, mutable box that survives every render cycle.

That distinction matters a lot once you start building things like interval timers, animation loops, or components that need to read the latest state inside a stale closure. useRef is the escape hatch React gives you when the data-flow model would otherwise get in its own way.

The Classic DOM Ref: Focus, Scroll, and Measurements

Start with the fundamentals. Attaching a ref to a DOM element lets you call imperative browser APIs — focus(), scrollIntoView(), getBoundingClientRect(). These are things React's declarative model can't easily express.

import { useRef } from 'react';

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

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} type="text" placeholder="Search..." />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

Notice the TypeScript generic: useRef<HTMLInputElement>(null). The null initial value matches the type React expects — null before the element mounts, the actual element after. You'll see a lot of code initialise with null! to avoid the optional-chaining on .current, but that's a bit risky and honestly just saves two characters. The ?. form is cleaner.

Interval and Timeout Refs: Clearing Stale Timers

Here's the thing: calling setInterval inside a component and forgetting to clear it is one of the most common memory leaks in React apps. The interval keeps firing after the component unmounts because you never saved a reference to it.

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

export function CountdownTimer({ seconds }: { seconds: number }) {
  const [remaining, setRemaining] = useState(seconds);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setRemaining(prev => {
        if (prev <= 1) {
          clearInterval(intervalRef.current!);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);

  return <span>{remaining}s</span>;
}

Using ReturnType<typeof setInterval> instead of number means this works correctly in both browser and Node environments — useful if you've got server-side rendering in the mix. The ref persists across renders, so the cleanup function always has access to the actual interval ID, not a stale copy captured in a closure.

Tracking Previous Values Without a State Update

Want to know what a prop or state value was on the last render? useState would trigger another render — a loop you don't want. useRef stores the value silently.

import { useRef, useEffect } from 'react';

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

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

  return ref.current;
}

// Usage inside a component:
const prevCount = usePrevious(count);
// After render: prevCount holds the value from last render cycle

This pattern is particularly handy for animation transitions — you need to know the old height before an element expands, or the previous active tab so you can slide in the right direction. It's also useful for deep-equality checks when you want to skip effects unless something meaningfully changed.

If you're building accessible UIs with focus management — something that comes up a lot when you're assembling components from a library like Empire UI — tracking the previously focused element and restoring focus on modal close is exactly where this hook earns its keep.

Mutable Instance Variables: Escaping Stale Closures

Closures in React hooks close over the values at the time they're created. This is a feature, not a bug — but it bites you when a callback defined in a useEffect reads a piece of state that has since changed. The callback still holds the old value.

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

export function LiveLogger({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  const labelRef = useRef(label);

  // Keep the ref in sync without adding label as an effect dependency
  useEffect(() => { labelRef.current = label; });

  useEffect(() => {
    const id = setInterval(() => {
      // Always reads the latest label, not the one captured at setup
      console.log(`[${labelRef.current}] tick`);
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // intentionally empty deps

  return <p>{count} ticks logged under "{label}"</p>;
}

This approach — sometimes called a "ref callback" or "mutable ref pattern" — lets you have a long-lived effect (empty dependency array) that still reads fresh values. You keep .current updated in a separate, cheap effect that runs every render.

It's a trade-off. You give up some explicitness around dependencies. For that reason it's worth adding a comment explaining why the dep array is empty. Future maintainers (including you at 2am) will thank you.

forwardRef and useImperativeHandle: Exposing Refs from Custom Components

React doesn't forward refs to custom components by default. If you do <MyInput ref={inputRef} />, that ref will be null. You have to opt in explicitly using forwardRef.

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

type FancyInputHandle = {
  focus: () => void;
  clear: () => void;
};

const FancyInput = forwardRef<FancyInputHandle, { placeholder?: string }>(
  ({ placeholder }, ref) => {
    const innerRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(ref, () => ({
      focus() { innerRef.current?.focus(); },
      clear() { if (innerRef.current) innerRef.current.value = ''; },
    }));

    return (
      <input
        ref={innerRef}
        placeholder={placeholder}
        className="border border-white/20 bg-white/5 rounded-lg px-4 py-2"
      />
    );
  }
);

FancyInput.displayName = 'FancyInput';

useImperativeHandle lets you control exactly what the parent gets. You're not exposing the raw DOM node — you're exposing a typed API. That makes it much easier to refactor internals later without breaking callers.

This pattern is used heavily in component libraries. If you're building or consuming components from Empire UI's design system, you'll run into this whenever a headless component needs to expose scroll position, validation state, or animation controls to its parent. Check out the guide on React toast notifications to see a real-world example where forwardRef solves the "trigger from outside" problem.

Ref Callbacks: Dynamic and Conditional Attachment

The ref prop doesn't have to receive a ref object. It can also receive a callback function — called a "ref callback" — that React invokes with the DOM element when it mounts and with null when it unmounts.

function MeasuredBox() {
  function handleRef(node: HTMLDivElement | null) {
    if (node) {
      const { width, height } = node.getBoundingClientRect();
      console.log(`Box size: ${width}x${height}`);
    }
  }

  return (
    <div ref={handleRef} style={{ padding: '8px' }}>
      Content here
    </div>
  );
}

Ref callbacks shine when you need to measure elements after they mount, or when you're conditionally attaching refs based on some runtime condition. They also compose more naturally when you need to attach multiple behaviors to the same node — just call each one from inside the callback.

Keep in mind that if you inline the callback as an anonymous function, React will call it with null and then re-call it with the element on every render, because the function identity changes. If the side effect is expensive, wrap the callback in useCallback. It's one of those subtle footguns that makes you stare at the profiler for twenty minutes wondering what's doing all those extra DOM reads.

Refs in TypeScript: Getting the Types Right

TypeScript generics on useRef have two forms and they mean different things. useRef<T>(null) gives you RefObject<T>.current is readonly and starts as null. useRef<T | null>(null) gives you MutableRefObject<T | null> — you can reassign .current yourself. React's internal ref handling expects RefObject for DOM refs.

For interval IDs and other mutable values you control, use MutableRefObject. For DOM nodes you pass to a JSX element's ref prop, use the readonly form. The difference trips up even experienced devs — check out the React TypeScript tips article for a broader look at getting generics right across hooks.

One more thing worth mentioning: if you're using React 19, the ref prop is now available directly on function components without needing forwardRef. The compiler handles forwarding automatically. If you're still on React 18.x or earlier though, forwardRef remains the way to go.

For animation-heavy work — especially if you're layering ref-driven measurements on top of something like a particles background — you'll want to ensure refs are typed tightly so TypeScript can catch the inevitable null access before it reaches production. A 16.67ms animation frame budget doesn't leave room for runtime errors.

FAQ

What's the difference between useRef and useState for storing values?

useState triggers a re-render when the value changes. useRef doesn't. Use useRef when you need to track something across renders but updating it shouldn't cause the component to re-render — like an interval ID, a previous value, or the latest copy of a prop inside a stale closure.

Why is my interval callback reading stale state even though I defined it inside the component?

Closures capture values at the time they're created. If your interval is set up in a useEffect with an empty dependency array, the callback captures the state value from the first render and never updates. Fix this by storing the latest value in a ref, updating that ref on every render, and reading from the ref inside the interval callback.

When should I use useImperativeHandle vs just forwarding the raw DOM ref?

Forward the raw DOM ref when the parent genuinely needs direct access to the element — like passing it to a third-party library. Use useImperativeHandle when you want to expose a controlled API instead. It protects your internals, makes the contract explicit, and lets you refactor the component without breaking callers.

Does useRef work with React Server Components?

No. useRef is a client-side hook. Server Components can't hold refs because there's no DOM and no component instance. Any component that uses useRef needs the 'use client' directive at the top of the file.

Why does my ref callback fire twice on every render?

If you're passing an inline anonymous function as the ref callback, React sees a new function on every render, calls the old one with null (cleanup), then calls the new one with the element. Wrap the callback in useCallback to stabilise its identity and it'll only fire on mount and unmount.

In React 19, do I still need forwardRef for custom components?

No. React 19 accepts ref as a regular prop on function components. You can destructure it directly: function MyInput({ ref, ...props }) { ... }. The forwardRef wrapper is deprecated but still works for backward compatibility. If you're on React 18 or earlier, forwardRef is still required.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Architecture & Patterns: The Complete 2026 GuideuseReducer Patterns: Complex State Without a State ManagerReact UI Components Complete Reference: 60+ Patterns with CodeBuilding Design Systems That Scale: Engineering Guide 2026