Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate React 16.8+ ~24 min· topic 1 of 6

Topic 8.1

Refs: Remembering Without Re-rendering

In one line

useRef gives a component a box, { current: value }, that keeps its value between renders but doesn't cause a re-render when you change it; use it for values the screen doesn't show, such as timer ids and previous values.

Think of it like this

A sticky note inside your school bag. You can write on it, read it, and it is still there tomorrow. But writing on it doesn't make the teacher re-check your work. State is like handing your homework in: the teacher looks again (React re-renders). A ref is the sticky note: your private memory that nobody re-checks when it changes.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Ref
A box made by useRef: an object { current: value } that React keeps for the whole life of the component.
useRef
The hook that creates a ref: const box = useRef(0). React returns the same object on every render.
current
The one property of a ref. You read and change the stored value through it: box.current = 5.
Mutable
Changeable in place. You change ref.current directly, without a setter function.
Snapshot
A fixed copy of values for one render. State is a snapshot; a ref is not.
Timer id
The number setInterval or setTimeout returns. You pass it to clearInterval or clearTimeout to stop the timer.
Lazy initialisation
Creating a value only the first time it is needed: if (ref.current === null) ref.current = create();.

Step by step

01Create a ref

Import useRef and call it at the top of your component with a starting value. You get an object with a current property.

Clicker.jsxwhole filejsx
import { useRef } from "react";

function Clicker() {
  const clicks = useRef(0); // { current: 0 }
  function handleClick() {
    clicks.current = clicks.current + 1;
    console.log("clicked", clicks.current, "times");
  }
  return <button onClick={handleClick}>Click me</button>;
}

02Change it in an event handler

The handler changes clicks.current. The number really goes up (the log proves it), but Clicker doesn't render again, because nothing told React anything changed. That is fine here: the screen never shows the number.

03Decide: ref or state?

Ask one question: does the screen show this value? If yes, use state, so React re-renders when it changes. If no, and only handlers or effects need it, use a ref.

Decide: ref or state?diagram
Rendering diagram…

04Store a timer id

setInterval returns an id. The Start button makes the timer; the Stop button, called in a later render, needs that same id. A normal let variable is created fresh on every render, so the Stop handler would see undefined. A ref keeps the id across renders.

Ticker.jsxwhole filejsx
const intervalRef = useRef(null);

function start() {
  clearInterval(intervalRef.current); // never run two timers
  intervalRef.current = setInterval(() => setTicks((t) => t + 1), 100);
}

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

05Remember the previous value

An effect runs after each commit. Before the effect overwrites the ref with the new value, the ref still holds the old one, so the effect can compare them. Reading and writing the ref happens inside the effect, never during render.

06Keep refs out of rendering

If JSX reads ref.current, the screen shows whatever the ref held at the last render and silently goes stale. If the render body writes it, StrictMode and concurrent rendering can run that write more times than you expect. Handlers and effects are the safe places.

Keep refs out of renderingdiagram
Rendering diagram…

Try it yourself

  1. 1

    Predict the stale screen

    In the first example, change the script to click Ref +1 five times before the state click. Predict both screen: lines before you run it. (0, then 5.)

    Now move the ref value into state instead: const [refLike, setRefLike] = useState(0). Does the screen update on every click now?

  2. 2

    Double Start

    In the stopwatch, delete the clearInterval(intervalRef.current) line at the top of start. Press Start twice, then Stop. Predict what happens, then try it. (Two timers run, the clock runs at double speed, and Stop only clears the second one, so it keeps counting.)

  3. 3

    Log the first value too

    In the price example, start the ref with useRef(null) and log first price: 100 when previous.current is null. Which render logs it?

Code & diagrams

State updates the screen, a ref doesn't New tab

Three ref clicks: the value really goes up to 3, but nothing renders and the screen still says 0. One state click renders, and only then does the screen catch up with the ref.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render, stateCount = 0
refCount.current is now 1
refCount.current is now 2
refCount.current is now 3
screen: Ref on screen: 0
render, stateCount = 1
screen: Ref on screen: 3
A stopwatch keeps its interval id in a ref New tab

The time is shown, so it is state. The interval id is never shown, so it lives in a ref, where the Stop handler of a later render can still find it.

Preview

Press Run to see the page.

Console

Nothing logged yet.
Remembering the previous value in an effect New tab

Nothing logs on the first render, because old and new are equal. After that, each change logs the old and the new price. The ref is read and written only inside the effect.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

price went up: 100 -> 110
price went up: 110 -> 120
price went down: 120 -> 115
Refs versus statediagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Keep the interval id in a normal variable

In the stopwatch, replace const intervalRef = useRef(null); with let intervalId = null; and use intervalId in start and stop. Press Start, then Stop.

terminal
$ Preview
── what you'll see ──
The clock keeps counting after you press Stop. Reset sets it to 0.0, and it keeps counting from there.

Break #2

Show a ref's value on screen

Build a like button with const likes = useRef(0), increase it in onClick and render {likes.current} likes.

terminal
$ Preview
── what you'll see ──
The button always says "0 likes", however many times you click it. No warning appears.

Myth vs fact

Myth

A ref is a faster useState.

Fact

A ref is not a replacement for state. It skips re-rendering, which is exactly why it can't drive the screen. Use it only for values the JSX doesn't show.

Myth

Refs are only for DOM elements.

Fact

A ref can hold anything: a number, a timer id, an object, a class instance. DOM elements are just the most common thing to keep in one (Topic 8.2).

Myth

It's fine to read refs during render if it works.

Fact

It may work today and show stale values tomorrow, when an unrelated change stops a re-render or concurrent rendering throws a render away. The React Compiler and its lint rule (Topic 16.5) also flag reading ref.current during render.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    useRef(value) behaves like useState({ current: value })[0]: one object per component instance, created on mount and kept on the fiber until unmount. The object is the same every render, so a ref is safe to leave out of effect dependency arrays; the lint rule knows refs from useRef are stable.

  • ▸

    In React 19 the TypeScript types changed: useRef needs an argument (useRef<number | null>(null)), and all refs are mutable RefObject<T> (the old read-only MutableRefObject split is gone). Phase 12 covers typing refs.

  • ▸

    A ref doesn't have the stale-closure problem of state: a callback created in an old render that reads ref.current sees the latest value. That is why some libraries keep "the latest callback" in a ref updated in a layout effect. Since React 19.2, useEffectEvent (Topic 7.9) is the built-in way to do that for effects.

  • ▸

    if (ref.current === null) ref.current = new ExpensiveThing() is the accepted lazy-init pattern because the result doesn't depend on when or how often it runs. useRef(new ExpensiveThing()) would construct a new object on every render and throw it away.

Remember this

  1. 1

    useRef(initialValue) is a hook (a special React function you call at the top of a component, Topic 4.2). It returns a plain object with one property, current. On the first render current is initialValue. On every later render React hands you the very same object, so whatever you stored in ref.current is still there. You read it with ref.current and change it with ref.current = newValue.

  2. 2

    The key difference from state: **changing ref.current does not trigger a render.** React doesn't know you changed it and doesn't care. With state you call a setter (setCount), React schedules a render, and the screen shows the new value. With a ref you change a property on an object, and nothing else happens. So: if the value is shown on screen, it belongs in state. If the value is only needed by your event handlers or effects, a ref is often the right tool.

  3. 3

    A ref is also mutable and instant. State is a snapshot: inside one render count never changes, and setCount only affects the *next* render (Topic 4.4). A ref is not a snapshot: after ref.current = 5, the very next line reads 5. That makes refs good for things like "is a request already running?" flags that event handlers must see immediately.

  4. 4

    Typical uses: storing a timer id from setInterval or setTimeout so a Stop button can clear it later; remembering the previous value of a prop so an effect can compare old and new; keeping an object that is expensive to create (a WebSocket, a chart instance) for the component's whole life; and, the most famous one, pointing at a DOM element (Topic 8.2).

  5. 5

    The rule: **don't read or write ref.current during rendering (the component body that builds JSX). Rendering must be pure (Topic 2.6): same props and state, same output. A ref can change without React knowing, so JSX that reads it can show a stale value, and code that writes it during render can run twice in StrictMode or be thrown away by concurrent rendering. Read and write refs in event handlers and effects** instead. The one accepted exception is lazy initialisation: if (ref.current === null) ref.current = new Thing();, which always gives the same result.

  6. 6

    Under the hood a ref is almost nothing: React stores the { current } object on the component's fiber (its record of the component, Topic 0.2) and returns it each time. You could build it yourself with const [ref] = useState({ current: initialValue }), and never call a setter. That is a good way to remember what a ref is: state that never re-renders.

Explain it without notes

01

What is the difference between a ref and state?

02

Why is a timer id a good fit for a ref, and what goes wrong with a normal variable?

03

Why shouldn't you read or write ref.current during rendering? Is there an exception?

04

How does a component remember the previous value of a prop?

Practice

01

Write a SearchBox that logs searching for: <text> only when the user stops typing for 300 ms (a debounce). Keep the timeout id in a ref.

02

Write a button that ignores clicks while a fake save (setTimeout of 500 ms) is running, using a ref as an isSaving flag, and logs saved when done.

Trade-offs

  • ↔

    Refs avoid renders but hide changes from React: nothing on screen updates, and DevTools shows no change. Use them only where that silence is what you want.

  • ↔

    Storing a value both in state (for the screen) and in a ref (for instant reads in handlers) keeps two copies in sync by hand. Prefer one source of truth unless measuring shows you need both.

Done when you can

  • I can create a ref with useRef and read and change ref.current.

  • I can explain why changing a ref doesn't re-render.

  • I can choose between a ref and state with the "does the screen show it?" question.

  • I can store a timer id in a ref and clear it later.

  • I can remember a previous value with a ref and an effect.

  • I know refs belong in handlers and effects, not in rendering, and the lazy-init exception.