useRef Hook

The useRef hook lets a function component hold onto a mutable value or a reference to a DOM node that survives across re-renders — without causing a re-render itself when it changes. It’s React’s escape hatch for the handful of cases where you need to step outside the declarative render flow: focusing an input, measuring an element, or storing a timer ID between renders. Unlike state, updating a ref never makes React re-run your component, which is exactly what you want for values the UI doesn’t need to display live.

Overview: How useRef Works

Calling useRef(initialValue) returns a plain JavaScript object shaped like { current: initialValue }. React creates this object once, on the component’s first render, and hands back that exact same object on every later render of that component instance. The object itself never changes identity — only the value stored at its current property does, and you are free to mutate that property directly with a plain assignment: myRef.current = something.

This is the core difference from useState. Calling a state setter schedules a re-render: React re-runs your component function, diffs the new JSX against the previous render during reconciliation, and commits the minimal set of changes to the real DOM. Mutating ref.current skips all of that. React never reads ref values while rendering and has no way to know they changed, so the screen stays exactly as it was until something else — a state update, a parent re-rendering — causes the component to render again for an unrelated reason. That is what makes refs perfect for “behind the scenes” bookkeeping: a previous value, an interval ID, a render count, a cache — anything your JSX doesn’t need to reflect directly.

useState useRef
Updating it triggers a re-render Updating it does not trigger a re-render
Value should be treated as immutable (replace, don’t mutate) Value is mutated directly via .current
New value is available starting the next render New value is available immediately, even within the same render
Use for anything the UI displays Use for DOM handles and internal bookkeeping

The second major use of useRef is attaching it to a JSX element with the special ref attribute, e.g. <input ref={inputRef} />. React reserves ref — it isn’t passed to your component as a regular prop — and uses it to hand you the real DOM node once one exists. The render → reconcile → commit cycle matters here: during render, React only builds a description of the UI (JSX turns into React elements), so no DOM exists yet and inputRef.current is still null. Only during the commit phase, after React has created or updated the actual DOM nodes, does it set inputRef.current to that node. That’s why DOM refs should always be read inside an event handler or a useEffect callback — both run after commit — never directly in the component’s function body during render.

Because updating a ref never triggers a re-render, refs also sidestep the “stale closure” problem the way state can suffer from it: reading ref.current inside any callback always returns the latest value, since there is only ever one object instance shared across renders. This makes refs a common — but easy to misuse — tool for reading a “current” value inside an effect or handler without adding it to a dependency array. Lean on this sparingly; overusing it hides real dependencies and can mask bugs that the dependency array would otherwise catch.

Syntax

import { useRef } from "react";

const myRef = useRef(initialValue);

// Attach to a DOM element
<div ref={myRef}>...</div>

// Read or write the current value
myRef.current;
myRef.current = newValue;
Part Meaning
useRef Hook imported from "react"; called at the top level of a component or custom hook, never conditionally.
initialValue The value current is set to on the very first render. Use null when the ref will hold a DOM node.
Return value A mutable object { current: initialValue } that keeps the same identity across every render of the component.
.current The property you read and write. Mutating it does not trigger a re-render or run any effect on its own.
ref={myRef} Special JSX attribute; when placed on a host element (like <div> or <input>), React sets myRef.current to that DOM node after commit.

Examples

Example 1: Autofocusing an Input on Mount

The most common use of useRef is grabbing a handle to a real DOM node so you can call an imperative browser API on it — here, .focus().

import { useEffect, useRef } from "react";

function SearchInput() {
  const inputRef = useRef(null);

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

  return (
    <input
      ref={inputRef}
      type="text"
      placeholder="Search..."
      className="search-input"
    />
  );
}

export default SearchInput;

Renders: a text input with the placeholder “Search…”. As soon as the component mounts, the browser automatically focuses the input — a blinking cursor appears in it without the user clicking.

inputRef starts out as null, the argument passed to useRef. React attaches it to the actual <input> DOM node once the element is committed, and only then does the effect (which runs after commit) fire and call inputRef.current.focus(). The empty dependency array [] means this effect runs exactly once, right after the first render.

Example 2: Tracking the Previous Value of State

State only ever gives you the current value — React discards the old one once a re-render happens. A ref is the standard way to remember “what was it last time,” because updating a ref during an effect doesn’t itself cause a new render.

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

function PreviousValue() {
  const [count, setCount] = useState(0);
  const previousCountRef = useRef();

  useEffect(() => {
    previousCountRef.current = count;
  }, [count]);

  const previousCount = previousCountRef.current;

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {previousCount === undefined ? "none" : previousCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default PreviousValue;

Renders: “Current: 0” and “Previous: none” with an Increment button. Clicking it once shows “Current: 1” / “Previous: 0”; clicking again shows “Current: 2” / “Previous: 1”, and so on.

On every render, previousCountRef.current still holds whatever count was during the previous commit, because the JSX reads the ref’s old value before the effect runs. Only after that render has been committed does the effect overwrite the ref with the new count, ready to be read as “previous” on the next render.

Example 3: A Stopwatch — Storing a Timer ID in a Ref

A realistic use of a non-DOM ref is holding onto an interval or timeout ID so a later event handler can clear it. The ID itself should never appear in the UI, so it doesn’t belong in state.

import { useState, useRef } from "react";

function Stopwatch() {
  const [elapsedMs, setElapsedMs] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalIdRef = useRef(null);

  function handleStart() {
    if (isRunning) return;
    setIsRunning(true);
    const startTime = Date.now() - elapsedMs;
    intervalIdRef.current = setInterval(() => {
      setElapsedMs(Date.now() - startTime);
    }, 100);
  }

  function handleStop() {
    clearInterval(intervalIdRef.current);
    setIsRunning(false);
  }

  function handleReset() {
    clearInterval(intervalIdRef.current);
    setIsRunning(false);
    setElapsedMs(0);
  }

  const seconds = (elapsedMs / 1000).toFixed(1);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
      <button onClick={handleReset}>Reset</button>
    </div>
  );
}

export default Stopwatch;

Renders: “0.0s” with Start, Stop, and Reset buttons. Clicking Start counts up every tenth of a second (“1.3s”, “1.4s”, …); Stop freezes the display at its current value; Reset returns it to “0.0s”.

intervalIdRef.current holds the number returned by setInterval. Storing it in state would work too, but it would trigger a pointless re-render every time it’s set, and the ID itself is never rendered. handleStop and handleReset read intervalIdRef.current to clear exactly the right timer, even though setting that ref never caused a re-render.

Under the Hood: Mount, Update, and Unmount

On mount, React first runs your component function to build the JSX tree; any ref you attached via ref={...} is still null or its initial value at this point, because no DOM nodes exist yet. React then commits — creates the real DOM nodes, inserts them into the page, and only now sets each ref’s current to the corresponding node. Any useEffect callbacks run after this commit, which is why they’re the first safe place to read a DOM ref.

On a state update elsewhere in the component (or a parent re-render), your component function runs again to produce new JSX. The ref object itself is untouched by this — useRef does not reset the value on re-render, unlike useState(initialValue), which is only used for the very first render. React reconciles the new JSX against the old, updates only what changed in the DOM, and refreshes ref.current if the underlying node changed (it usually stays the same node, so ref.current stays the same too).

On unmount, React detaches DOM refs by setting current back to null after removing the node from the page. It does not automatically clean up values you stored yourself in a ref — a running setInterval ID, for instance, keeps ticking even after the component is gone unless your effect’s cleanup function calls clearInterval. Always pair a side effect you start (timers, subscriptions) with a cleanup function in the same useEffect, whether or not you’re tracking the resource in a ref.

Common Mistakes

Mistake 1: Expecting the UI to Update When a Ref Changes

function ClickCounter() {
  const countRef = useRef(0);

  function handleClick() {
    countRef.current = countRef.current + 1;
    console.log(countRef.current);
  }

  return (
    <div>
      <p>Clicks: {countRef.current}</p>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

Why it’s wrong: setting countRef.current only changes the value stored in the ref object; it does not schedule a re-render. The console.log call proves the value really is incrementing internally, but the paragraph showing “Clicks: {countRef.current}” was rendered once with the value from that render and will never repaint on its own — the text stays stuck at whatever it was the last time the component actually re-rendered for some other reason.

import { useState } from "react";

function ClickCounter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount((c) => c + 1);
  }

  return (
    <div>
      <p>Clicks: {count}</p>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
}

export default ClickCounter;

Switching to useState fixes it: every click now schedules a re-render, so the displayed count stays in sync with the actual value.

Mistake 2: Reading a DOM Ref During Render

function Box() {
  const boxRef = useRef(null);
  const width = boxRef.current.offsetWidth;

  return <div ref={boxRef}>Width: {width}</div>;
}

Why it’s wrong: on the component’s first render, React hasn’t created the DOM node yet — that only happens during the commit phase, after the function body finishes running. So boxRef.current is still null when this line executes, and calling .offsetWidth on null throws a runtime error. Code that reads a DOM ref must run after commit: inside a useEffect, or inside an event handler triggered by user interaction.

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

function Box() {
  const boxRef = useRef(null);
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(boxRef.current.offsetWidth);
  }, []);

  return <div ref={boxRef}>Width: {width}px</div>;
}

export default Box;

Moving the read into a useEffect guarantees the DOM node already exists, and storing the measurement in state lets React re-render once the real width is known.

Best Practices

  • Use useRef for values the UI doesn’t need to display: DOM handles, timer or interval IDs, previous values, mutable caches, and flags like “has this effect already run.”
  • Reach for useState instead whenever a value should cause the component to re-render when it changes — refs are invisible to React’s rendering.
  • Never read or write a DOM ref’s .current during render itself; only inside event handlers or useEffect callbacks, which run after the DOM has been committed.
  • Initialize DOM refs with useRef(null) and account for the possibility that .current is still null — it is, until after the first commit, and again after unmount.
  • Always clean up anything you stored a handle to in a ref — clear intervals, cancel subscriptions — inside your effect’s cleanup function, not just when a button happens to be clicked.
  • Avoid using a ref as a substitute for state just to “avoid a re-render” — if the value affects what’s rendered, hiding it in a ref only produces a UI that silently falls out of sync.

Practice Exercises

  1. Build a video player component with a video element and two buttons, Play and Pause, that call the native .play() and .pause() methods on the video element through a ref instead of any built-in React prop.
  2. Write a custom hook called useRenderCount that uses a ref to count how many times the calling component has rendered, and returns that count. Use it in a component and confirm the number only increases on actual re-renders, not on ref updates alone.
  3. Extend the stopwatch example with a “Lap” button that, each time it’s clicked, records the current elapsed time in a list. Decide carefully whether that list should live in a ref or in state, and be ready to explain why.

Summary

  • useRef(initialValue) returns a mutable object, { current: initialValue }, that keeps the same identity across every render.
  • Changing ref.current never triggers a re-render — use it for values the UI doesn’t need to reflect directly.
  • Passing a ref to a JSX element’s ref attribute gives you the real DOM node, but only after the commit phase — read it in effects or event handlers, never during render.
  • Common uses: focusing or measuring DOM elements, storing timer or subscription IDs, remembering a previous value, and counting renders.
  • Reach for useState whenever a change should be visible on screen; reach for useRef when it shouldn’t be.