Skip to main content

useRef: The Mutable Box

What you'll learn

  • What useRef actually returns, a plain, stable box, and why its identity never changes
  • Why mutating .current never causes a re-render, and when that is exactly what you want
  • The three everyday use cases: DOM access, invisible mutable values, and the "latest value" pattern
  • What refs are NOT for, plus a ref-vs-state decision table
  • forwardRef: passing a ref through your component to a DOM node inside it

Sometimes you need to remember something that has nothing to do with what is on screen: a timer ID, a DOM node, the room the user is in right now. State is the wrong tool for these, changing state re-renders, and you don't want a re-render. React's answer is almost disappointingly simple: a plain object with one property.

The box

useRef(0) returns { current: 0 }. That is the whole API. Two facts make it powerful:

  1. It is the same object on every render. Render once or a thousand times, you get the identical box back.
  2. Mutating it tells React nothing. Writing ref.current = 5 causes no re-render, ever.

You can watch both facts misbehave in one tiny component:

import { useRef, useState } from 'react';

export default function RefVsState() {
  const ref = useRef(0);
  const [count, setCount] = useState(0);

  function bumpRef() {
    ref.current += 1;
    console.log('ref.current is now', ref.current);
  }

  return (
    <div>
      <p>state count: {count}</p>
      <p>ref count (watch it go stale!): {ref.current}</p>
      <button onClick={bumpRef}>Increment the ref</button>
      <button onClick={() => setCount(count + 1)}>Increment state</button>
    </div>
  );
}

What happens:

  1. Click "Increment the ref" three times. The console prints 1, 2, 3. The screen still shows ref count: 0, no render happened, so the paragraph is stale.
  2. Click "Increment state". React re-renders, the paragraph reads the box again, and it jumps straight to 3, catching up all at once.
  3. The lesson: the screen updates when React renders, and mutating a ref does not render.

(Reading ref.current in JSX like this is normally a mistake, precisely because it goes stale. We do it here only to watch it happen.)

Pseudocode model, not real source:

// Per component instance, React keeps one storage cell per hook call.
function useRef(initialValue) {
const cell = getCurrentHookCell();
if (cell.box === undefined) {
cell.box = { current: initialValue }; // created ONCE
}
return cell.box; // the SAME object, every single render
}

Jargon: "escape hatch". A sanctioned way to step outside React's normal rules when you genuinely need to. Refs are the escape hatch out of the reactive system (state → render → UI): a place React deliberately does not watch.

Use case 1: grabbing DOM nodes

The most familiar use: put the box in a ref attribute, and React fills it with the real DOM node after commit:

import { useRef } from 'react';

export default function SearchForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <div>
      <input ref={inputRef} placeholder="Type to search…" />
      <button onClick={handleClick}>Focus the input</button>
    </div>
  );
}

What happens:

  1. First render: inputRef.current is null, the DOM node does not exist yet.
  2. During commit, React creates the real <input> and assigns it to inputRef.current because of the ref={inputRef} attribute.
  3. By the time your click handler runs, the box holds the actual DOM node, and .focus() works.
  4. When the input is removed from the page, React sets the box back to null.

The same box works for measuring:

import { useRef, useState } from 'react';

export default function Measurer() {
  const boxRef = useRef(null);
  const [size, setSize] = useState(null);

  function measure() {
    const rect = boxRef.current.getBoundingClientRect();
    setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
  }

  return (
    <div>
      <div ref={boxRef} style={{ width: '50%', height: 120, background: '#def' }}>
        Resize the window, then press the button
      </div>
      <button onClick={measure}>Measure</button>
      {size && <p>{size.width} × {size.height} px</p>}
    </div>
  );
}

Jargon: "callback ref". A function passed to the ref attribute instead of a useRef box. React calls it with the DOM node when the node is attached, and with null when it is removed.

Why use a function instead of a box? Because the two calls, node on attach, null on detach, are exactly mount and unmount moments for that DOM node. A box can only be read later; a callback ref tells you the instant the node appears:

import { useCallback, useState } from 'react';

export default function MeasureOnMount() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback((node) => {
    if (node !== null) {
      // The exact moment the node exists in the DOM:
      setHeight(node.getBoundingClientRect().height);
    }
    // When the node is removed, React calls this again with null.
  }, []);

  return (
    <div>
      <p ref={measuredRef} style={{ lineHeight: 1.5 }}>
        I get measured the instant I exist — no button, no effect.
      </p>
      <p>That paragraph is {Math.round(height)}px tall.</p>
    </div>
  );
}

(Newer React versions also let a callback ref return a cleanup function instead of receiving the null call, same idea, tidier syntax.)

Use case 2: values that should not re-render

An interval ID is a number the browser gives you. The UI never displays it; you only need it later to cancel. That makes it ref material, not state:

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

export default function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    if (intervalRef.current !== null) return; // already running
    intervalRef.current = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  useEffect(() => {
    return () => clearInterval(intervalRef.current); // cleanup on unmount
  }, []);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
      <button onClick={() => setSeconds(0)}>Reset</button>
    </div>
  );
}

What happens:

  1. start() stores the interval ID in intervalRef.current. The ID is bookkeeping, invisible, so it must not be state.
  2. Every second, the interval fires setSeconds(s => s + 1). That is state, because the UI shows it.
  3. stop() reads the ID out of the box and cancels it. No render is needed for any of this bookkeeping.
  4. The effect's cleanup uses the same box if the component unmounts while running.

Notice the division of labor: seconds is state because the screen shows it; the interval ID is a ref because only your code needs it. And beware the classic bug this avoids: a plain let intervalId declared in the component body is reset on every render, the box survives; a local variable does not.

Use case 3: the "latest value" pattern

Async callbacks close over the values from the render in which they were created. If state changes before the callback fires, the callback still sees the old value. A ref fixes it, because every render can write into the same box that every callback reads:

import { useRef, useState } from 'react';

function sendMessage(roomId, text) {
  console.log(`Sending "${text}" to room ${roomId}`);
}

export default function ChatRoom() {
  const [roomId, setRoomId] = useState('general');
  const [text, setText] = useState('');

  const roomRef = useRef(roomId);
  roomRef.current = roomId; // keep the box in sync during render

  function handleSend() {
    const draft = text;
    setTimeout(() => {
      // With plain `roomId` here, this closure would send to the OLD room.
      // The ref always holds the room from the LATEST render.
      sendMessage(roomRef.current, draft);
    }, 3000);
  }

  return (
    <div>
      <select value={roomId} onChange={(e) => setRoomId(e.target.value)}>
        <option value="general">#general</option>
        <option value="random">#random</option>
      </select>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleSend}>Send (3s delay)</button>
    </div>
  );
}

What happens:

  1. You select #general, type "hi", and click Send. The timeout callback closes over the render where roomId was 'general'.
  2. Within 3 seconds, you switch to #random. State updates and the component re-renders, but the old callback still holds 'general'.
  3. During that newest render, roomRef.current = roomId re-synced the box to 'random'.
  4. The timer fires: roomRef.current reads 'random'. The message goes to the room you are looking at now, not the room you were in when you clicked.

Writing roomRef.current = roomId during render is the one sanctioned render-time ref write: it only mirrors state, and you never read the box during render. It is the standard way to give async code access to current values.

Jargon: "stale closure". A function that captured a value from an older render and keeps seeing that old value even after newer renders exist. Refs cure it because all renders write into one shared box.

What refs are NOT for

  • Anything the UI displays. If a value changes and the screen must change with it, that value is state. Store a user's typed name in a ref and the screen will never show it.
  • Replacing state to "avoid re-renders". You will not get a faster app; you will get a UI that is silently out of date.
  • Reading or writing .current during render (other than the mirror pattern above). Render output should be predictable from props and state alone; a ref is an invisible extra input.

Ref vs state

QuestionuseStateuseRef
Does changing it re-render?YesNo
Same identity every render?New value per renderOne box, forever
Safe to read during render?YesNo (except one-time init)
The UI depends on the value?Must be stateNever
Typical contentsData the user seesDOM nodes, timer IDs, latest-value mirrors

forwardRef: passing the box through

A parent that wants to focus your component's inner <input> has a problem: ref is not a normal prop, React intercepts it. forwardRef lets your component receive a ref and hand it down to a real node inside:

import { forwardRef, useRef } from 'react';

const FancyInput = forwardRef(function FancyInput(props, ref) {
  return <input ref={ref} className="fancy" {...props} />;
});

export default function LoginForm() {
  const nameRef = useRef(null);

  return (
    <div>
      <FancyInput ref={nameRef} placeholder="Your name" />
      <button onClick={() => nameRef.current.focus()}>Focus name</button>
    </div>
  );
}

What happens:

  1. LoginForm creates a box and passes it as ref to FancyInput.
  2. Because FancyInput is wrapped in forwardRef, React delivers that box as the second argument, and FancyInput attaches it to its inner <input>.
  3. The parent's box now points at the real DOM node inside the child, nameRef.current.focus() works from outside.

(Newer React versions also let function components receive ref as a regular prop without forwardRef; you will see both styles in the wild.)

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: setting ref.current triggers a re-render. Reality: nothing happens, React does not watch the box. The screen updates only when something else renders.
  • Misconception: refs are only for DOM elements. Reality: any value you want to keep without re-rendering, timer IDs, previous values, websocket handles.
  • Misconception: useRef(initial) re-initializes the box each render. Reality: the box is created once; the argument is ignored after the first render.
  • Misconception: you can read .current in JSX like state. Reality: it will be stale the moment you mutate without rendering, read refs in handlers and effects.
  • Misconception: a ref is "state that doesn't re-render". Reality: it is outside the reactive system entirely; React neither tracks it nor diffs it.

Why it works this way

  • A stable box is possible because React stores it on the component's hook cell, one slot per hook call, kept across renders.
  • React cannot know when you write .current, it is a plain property on a plain object, and intercepting that would require wrapping every box in a proxy. Refs are deliberately the cheap, untracked path.
  • Handlers and effects always run after render, so giving them a shared mutable box never conflicts with render's purity.
  • DOM access has to go through a box because the node does not exist until after commit, you cannot receive it as a normal return value.

Try it yourself

  1. In RefVsState, click the ref button five times, then the state button: the ref paragraph jumps straight to 5. You have now seen staleness.
  2. In the Stopwatch, replace intervalRef with a plain let intervalId = null in the component body. Start, then try to stop, you can't; every render resets the variable.
  3. In the chat demo, replace roomRef.current with the roomId state variable inside the timeout. Send, switch rooms quickly, the message goes to the wrong (old) room.
  4. Give the callback-ref example a button that toggles the paragraph on and off; watch the callback fire with the node, then with null, on each toggle.

Recap

  • useRef(x) returns { current: x }, the same object on every render.
  • Mutating .current never re-renders; refs live outside the reactive system by design.
  • DOM access: put the box in the ref attribute; React fills it after commit and empties it on unmount. Callback refs hand you the mount/unmount moments directly.
  • Invisible bookkeeping, interval IDs, sockets, subscriptions, belongs in refs, not state.
  • Latest-value pattern: mirror state into a ref during render so async callbacks read the current value, not a stale closure.
  • If the UI shows it, it is state. If only your code needs it, it is a ref.
  • forwardRef passes a ref through your component to a DOM node inside it.

Next

useMemo and useCallback →