useRef vs useState

⭐ Interview Importance: MEDIUM
⏱️ Revision Time: 2 min

useRef vs useState

Both useRef and useState allow you to store data that persists across component renders. However, they are used for fundamentally different purposes.

The Golden Rule

  • Use useState for values that should trigger a re-render when they change (e.g., text in an input, a toggle switch, fetched data that is displayed).
  • Use useRef for values that should NOT trigger a re-render when they change (e.g., timer IDs, DOM nodes, keeping track of the previous state).

Comparison

FeatureuseStateuseRef
Updating the valueTriggers a component re-render.Does not trigger a re-render.
MutabilityImmutable. You must use the state setter function to update it.Mutable. You can modify ref.current directly.
Reading the valueYou can read it anywhere in your component.You should only read/write it in event handlers or effects, not during rendering.
Primary Use CaseData that affects the visual UI.Storing DOM nodes or keeping persistent background data.

Example: The Stopwatch

To illustrate the difference, consider a Stopwatch component.

We need to track two things:

  1. timeElapsed: This needs to update the UI every 10 milliseconds, so it must be State.
  2. intervalId: We need to store the ID returned by setInterval so we can clear it later. Changing this ID does not change the UI, so it must be a Ref.
import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  
  // We use a ref to store the interval ID. If we used state for this,
  // setting the ID would cause an unnecessary re-render!
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    // clearInterval expects the ID, so we store it in .current
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    // We read .current to clear the interval
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
    </>
  );
}

Why not just use regular variables?

You might wonder why we can’t just use a normal let variable outside or inside the component instead of useRef.

let timerId = null; // Defined outside

function Component() {
  // ...
}

If you define it outside, that variable is shared across all instances of the component. If you render <Stopwatch /> twice on the same page, clicking “Start” on one will overwrite the timerId of the other!

function Component() {
  let timerId = null; // Defined inside
  // ...
}

If you define it inside, that variable is completely recreated and reset to null every single time the component re-renders!

useRef solves this: it is localized to the specific component instance, and it survives re-renders.

Interview Questions

Q: What is the absolute primary difference between useRef and useState?
A: Mutating a state variable inherently triggers a component re-render so the UI can update. Mutating a ref’s .current property does NOT trigger a re-render. Refs are an “escape hatch” to store background mutable data (like interval IDs) that shouldn’t affect the visual output.