React

useRef to mutate, not to store state

useRef keeps a mutable value without a re-render: use it for timers, identifiers and internal counters, not for anything shown on screen.

Key points

  • useRef keeps a mutable value without scheduling a render.
  • Anything that must appear on screen stays state, not a ref.
  • Mutate current in an effect or an event handler, never in the render body.
  • A ref belongs to one instance: it is neither a shared cache nor a global variable.

useRef keeps a mutable value for the life of the component without asking for a render: use it for identifiers, timers and internal counters, not for anything that must appear on screen.

How it differs from state

State is the value React draws: changing it schedules a render. A ref is a box with one current property: writing it schedules nothing, and React does not read it during render. That makes a ref the right place for a timer id, a DOM node or a counter nobody has to see.

The case: a debounced search field

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

export function SearchBox({ onQuery }) {
  const timer = useRef(null);
  const requests = useRef(0);
  const [value, setValue] = useState("");

  useEffect(() => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      requests.current += 1;
      onQuery(value);
    }, 300);

    return () => clearTimeout(timer.current);
  }, [value, onQuery]);

  return  setValue(e.target.value)} />;
}

The timer id and the request count live in refs: neither appears in the markup, so neither should re-render the field on every keystroke. The typed text is state, because the input displays it.

Mutating an object instead of rebuilding it

A ref can hold an object that survives renders: a Map used as a cache, an event buffer, a cancellation flag. Mutating in place keeps the same identity, so no effect restarts just because the container is new.

const cache = useRef(new Map());

function remember(key, value) {
  cache.current.set(key, value);   // mutation, no render
}

The cost sits in the initialiser: new Map() is evaluated on every render and then discarded. For an expensive object, initialise it on first read, with a check on current.

Where mutation breaks

  • During render: writing current in the component body runs twice under StrictMode and gives different values across concurrent renders.
  • As a source of truth: when the mutated value must appear on screen, React cannot know and the interface falls behind; that is state.
  • On a DOM node: assign the ref in an effect or a callback ref, never read it before mount, or current is null.

The command

npm create vite@latest ref-demo -- --template react-ts
cd ref-demo
npm install
npm run dev

Vite, released under the MIT licence, creates the project and starts the development server; you edit the component in src/App.tsx without touching configuration.

Live records in the catalogue

Three records show refs driving behaviour: ai-chat-input, which measures the field height, use-image-upload, a hook built on a ref, and the input category with its published fields.

The limitation

A ref notifies nobody: whoever reads it must know when to do so, and two components cannot synchronise through it. Mutation is also invisible in the React DevTools timeline, so a wrong value in current is found only by reading the code.

Where to continue

The boundary between refs, DOM and state is in refs, DOM and state; the order of calls is in rules of hooks; double effects in development are in useEffect running twice; the framework comparison is in Vue 3 vs React.

Frequently asked questions

When do I use useRef instead of useState?

When the value does not appear in the markup: timer ids, DOM nodes, internal counters. If the render has to show it, use useState.

Why does useRef not trigger a re-render?

Because React does not read current during render and does not record writes: the box stays outside the update cycle.

Can I mutate an object held in a ref?

Yes, outside render. In-place mutation is why the ref exists, but inside the component body it breaks StrictMode and concurrent rendering.

Why is current null on the first render?

Because the ref is attached to the node only after mount. Read it in an effect or a callback ref, not during render.

Does useRef replace a global variable?

No. Each component instance owns its ref: it shares nothing between components and does not survive unmount.