跪拜 Guibai
← Back to the summary

Stop Storing Everything in useState: Three Patterns Where useRef Gets It Right

Using useState right away? You're probably using it wrong — three correct ways to use useRef

useState stores a counter, why doesn't the page update? Thoroughly understand useRef


You write a counter:

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

  const add = () => {
    count += 1;           // changed count
    console.log(count);   // prints 1 indeed
  };

  return <div onClick={add}>{count}</div>;
}

Click it, the console prints 1. But on the page — nothing moves, still 0.

You pause for a moment, change it to setCount(count + 1). Good, the page updates.

But two days later you encounter a new scenario: you need to hold a Web Worker instance in a component to send messages to the worker. You store it with useState:

const [worker, setWorker] = useState(null);

useEffect(() => {
  const w = new Worker(new URL('./worker.js', import.meta.url));
  setWorker(w);          // ⚠️ triggers an unnecessary render
}, []);

The page works fine. But something feels off — I'm just storing a worker reference, why does React need to re-run the entire render process?

This article solves that confusion: what exactly is useRef, and when should you use it instead of useState.


In one sentence, what exactly is useRef

useRef is a "non-reactive mutable box" provided by React — it can persist a value across multiple renders, but modifying it does not trigger a component re-render.

Breaking it down:

Understand it with a real-life analogy:

useState is like your bank account balance — every change sends you an SMS notification (triggers a render),
you can see the latest number in the app.

useRef is like a sticky note in your pocket — you can change the number on it anytime,
but no one actively notifies you of the update. Only when you look down yourself (manually read ref.current) do you know.

This difference determines two completely different usage scenarios.


Scenario 1: Binding DOM nodes (the usage you're most familiar with)

After the page loads, an input field automatically gains focus — this is the most classic useRef scenario:

import { useRef, useEffect } from 'react';

function App() {
  const inputRef = useRef(null); // 🔑 initially null, will later point to the real DOM

  useEffect(() => {
    console.log(inputRef.current); // after mount, current already points to the input element
    inputRef.current.focus();      // directly call native DOM API
  }, []);

  return <input type="text" ref={inputRef} placeholder="Please enter username" />;
}

Result:

<input type="text" placeholder="Please enter username">   // console.log output
(input field auto-focuses after page load)

What ref={inputRef} does here is very simple: after React creates the real DOM node for this <input>, it stuffs its reference into inputRef.current.

Why can't you store DOM with useState?

// ❌ Wrong approach
const [inputEl, setInputEl] = useState(null);

// How do you plan to get the DOM node? Use a ref callback?
// <input ref={(el) => setInputEl(el)} />
// This does get it — but causes a double render:
// First render: inputEl = null
// setInputEl(domNode) triggers second render: inputEl = domNode  ← completely unnecessary

A DOM node is an "external object", it does not belong to React's data flow. Storing it with useState imposes a layer of React's reactive overhead, and this overhead is completely meaningless — DOM node changes don't need a re-render because React already knows.


Scenario 2: Storing mutable values that don't trigger renders

Look at this counter:

function App() {
  const numRef = useRef(0); // 🔑 mutable object, initial value 0
  const [, forceRender] = useState(0); // ⚠️ just a "manually trigger render" trick

  console.log(numRef.current); // prints current value on every render

  return (
    <div onClick={() => {
      numRef.current += 1;   // directly modify current, no render triggered
      forceRender(x => x + 1); // manually force render so you can see the change
    }}>
      {numRef.current}
    </div>
  );
}

Wait — if modifying numRef.current doesn't trigger a render, how do I let the user see the change? The code above uses a workaround: forceRender. But this is just a demo display trick.

The real difference is here

Comparing useRef and useState side by side, the difference is clear at a glance:

useState useRef
Read method count ref.current
Modify method setCount(newValue) ref.current = newValue
Triggers render after modification? ✅ Yes ❌ No
Value stable during render? ✅ Yes (unchanged within the same render) ⚠️ No (mutable at any time)
Typical use UI data, form inputs, toggle states DOM references, timer IDs, Worker instances

Key insight: useRef doesn't exist to "save one render." Its design purpose is to let you hold things that inherently do not belong to UI state — things that don't need to update the interface when changed, but need to maintain the same reference across multiple renders.

So what "inherently does not belong to UI state"? Look at the third scenario.


Scenario 3: Holding "external objects" — the most underrated usage

Your page needs to do a complex computation (like LLM inference, image processing, sorting large datasets), and doing it on the main thread would freeze the interface. So you decide to spin up a Web Worker:

graph LR
    A["Main Thread ─ React Component"] -->|"new Worker()"| B["Worker Thread"]
    B -->|"postMessage"| C["Complex Computation"]
    C -->|"postMessage returns result"| A
    A -->|"setState"| D["Update UI"]

The simplest implementation:

import { useRef, useEffect } from 'react';

function App() {
  const workerRef = useRef(null); // 🔑 persist Worker reference, but don't trigger renders

  useEffect(() => {
    // Spin up Worker thread — expensive operation, only needs to be done once
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)
    );

    // ⚠️ Easy mistake: must clean up Worker on component unmount, otherwise memory leak
    return () => {
      workerRef.current?.terminate();
    };
  }, []);

  return <>{/* Worker runs in background, doesn't occupy UI state */}</>;
}

Why must Worker use useRef and not useState?

There are three layers of reasons, each deeper than the last:

Layer 1 — Semantic layer: Worker does not belong to UI state. Your interface doesn't need to re-render based on "which Worker instance it is." Worker is infrastructure, not data.

Layer 2 — Performance layer: Storing Worker with useState triggers a meaningless render. Although one time doesn't matter, this is the beginning of code rot — every "doesn't matter" eventually piles into a mountain of technical debt.

Layer 3 — Correctness layer: This is the most easily overlooked critical point. Suppose you store Worker with useState, then send a message in some event handler:

// ❌ Hidden danger of storing Worker with useState
const [worker, setWorker] = useState(null);

useEffect(() => {
  const w = new Worker(new URL('./worker.js', import.meta.url));
  setWorker(w);
}, []);

const handleClick = () => {
  worker.postMessage('hello'); // ⚠️ If this is the closure from the component's first render,
                                // worker is still null!
};

Every render in React has its own props, state, and event handlers. If you captured worker in the first render's closure (which was null at the time), and later setWorker updates it, the worker in that old closure is still null.

useRef solves this problem: the ref object's reference remains unchanged throughout the component's entire lifecycle, you always access the latest value via workerRef.current, no closure traps.

useRef gives you not a "snapshot", but a "pointer". Snapshots go stale, pointers always point to the latest value.


Look back once more: that commented-out code

In the demo code, there's a commented-out for loop:

// Commented-out main thread blocking code
// for(let i = 0; i < 100000000; i++) {
//   console.log(i)
// }

What happens if this code is uncommented? The browser freezes — 100 million iterations occupy the main thread completely, during which all user interactions (clicks, scrolling, typing) become completely unresponsive.

This is why you need the Worker + useRef combination:

The two work together: React components focus on UI rendering, Worker focuses on computation, useRef builds a bridge between them.


Summary: One table to clarify useState vs useRef

You have a value that needs to be stored in a component. What should you use?

Does the interface need to update when the value changes?
├── Yes → useState
└── No
    ├── Is it a DOM node reference? → useRef + ref attribute
    ├── Is it a Worker / timer ID / WebSocket / AbortController? → useRef
    └── Is it a plain variable, but not UI state? → useRef

Remember in one sentence: useRef is a "non-reactive enclave" within React's reactive world — you can store anything in it, React doesn't know when it changes, and React shouldn't know.


Next time you write code, you can do this

Open question: Have you encountered bugs in your projects caused by misusing useState to store values that shouldn't be reactive? Like timer IDs, WebSocket connections, or other scenarios? Discuss in the comments.