Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 9 of 17 · ~4 min

useRef and Refs

Two chapters have now created a need for a value that survives re-renders without causing them. Here is the tool.

useRef(initial) returns an object, { current: initial }, and hands you the same object on every render of that component. Writing to .current changes it for every render that follows and schedules nothing. That is the exact opposite trade-off from useState, and the two cover between them almost everything you need to remember something.

Two uses account for nearly all real refs.

Reaching a real DOM node. When the drawer opens, the cursor should already be in the notes field — a reviewer working through fifty submissions should not have to click:

function ReviewForm({ candidateId }) {
  const notesRef = useRef(null);

  useEffect(() => {
    notesRef.current.focus();       // .current is the real <textarea> by now
  }, [candidateId]);

  return <textarea ref={notesRef} />;
}

Passing a ref object as the ref attribute tells React to put the DOM node in .current after it mounts, and null when it unmounts. .focus() and .scrollIntoView() have no declarative equivalent — there is no <textarea focused> — so this is the supported escape hatch, not a workaround. (autoFocus would cover the mount case, but our drawer stays mounted and swaps candidates, so the effect keyed on candidateId is what actually does the job.)

Holding a mutable value that must not trigger a render. The autosave timer's id, so the cleanup can clear it. The notesRef from the last chapter, holding the latest notes for an interval that does not re-run. A "what was this last render" value for comparison. None of those appear on screen, and putting them in state would cause a render for no visible reason — or, for the interval id, an infinite loop.

const timerRef = useRef(null);

function startAutosave() {
  timerRef.current = setInterval(save, 10000);
}
function stopAutosave() {
  clearInterval(timerRef.current);
}

The rules that come with the escape hatch

Anything that has to show up on screen belongs in state, not a ref. Keeping selectedId in a ref because "it doesn't need to cause a re-render" means the row highlight never moves, and the fix people reach for next — a dummy setTick(t => t + 1) to force a render — is a state variable with extra steps and worse ergonomics.

Do not read or write .current during rendering. Render functions are supposed to be pure: same props and state in, same JSX out, no side effects. Mutating a ref mid-render breaks that, and it breaks in ways that only show up under concurrent rendering, where React may start a render, abandon it, and start again. Reads and writes belong in effects and event handlers. The single exception is lazily initialising a ref that must be created once, and even there the conventional form is a null check.

A ref updating does not notify anybody. No re-render, no effect, no memo invalidation. If you need to react to a DOM node arriving — measuring it, observing it — an ordinary ref object will not tell you when it lands. A ref callback will, because React calls it with the node on mount:

<div ref={(node) => {
  if (!node) return;
  const observer = new ResizeObserver(handleResize);
  observer.observe(node);
  return () => observer.disconnect();   // React 19: ref callbacks may return cleanup
}} />

In React 19 a ref callback can return a cleanup function, called when the node is removed. Before 19, React called the callback with null on unmount and you handled teardown there — you will still see that form everywhere.

Two more pieces, briefly, because you will meet them.

Refs are now ordinary props. In React 19 you can accept ref in a function component's props and pass it through like anything else. forwardRef — the wrapper that used to be mandatory for letting a parent reach into your component's DOM node — is no longer needed for new code, though it is in every codebase written before 2025.

useImperativeHandle lets a component choose what its ref exposes, so a parent gets { focus() {}, clear() {} } instead of a raw DOM node. Useful for a genuine component API; easy to overuse, since most of what it is reached for is better expressed as props.