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.currentdirectly, 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
setIntervalorsetTimeoutreturns. You pass it toclearIntervalorclearTimeoutto 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.
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.
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.
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.
Try it yourself
- 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, then5.)Now move the ref value into state instead:
const [refLike, setRefLike] = useState(0). Does the screen update on every click now? - 2
Double Start
In the stopwatch, delete the
clearInterval(intervalRef.current)line at the top ofstart. 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
Log the first value too
In the price example, start the ref with
useRef(null)and logfirst price: 100whenprevious.currentisnull. Which render logs it?
Code & diagrams
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
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: 3The 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
Console
Nothing logged yet.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
Console
Nothing logged yet.Expected console output
price went up: 100 -> 110
price went up: 110 -> 120
price went down: 120 -> 115Break 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.
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.
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 likeuseState({ 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 fromuseRefare stable. - ▸
In React 19 the TypeScript types changed:
useRefneeds an argument (useRef<number | null>(null)), and all refs are mutableRefObject<T>(the old read-onlyMutableRefObjectsplit 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.currentsees 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
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 rendercurrentisinitialValue. On every later render React hands you the very same object, so whatever you stored inref.currentis still there. You read it withref.currentand change it withref.current = newValue. - 2
The key difference from state: **changing
ref.currentdoes 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
A ref is also mutable and instant. State is a snapshot: inside one render
countnever changes, andsetCountonly affects the *next* render (Topic 4.4). A ref is not a snapshot: afterref.current = 5, the very next line reads5. That makes refs good for things like "is a request already running?" flags that event handlers must see immediately. - 4
Typical uses: storing a timer id from
setIntervalorsetTimeoutso 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 (aWebSocket, a chart instance) for the component's whole life; and, the most famous one, pointing at a DOM element (Topic 8.2). - 5
The rule: **don't read or write
ref.currentduring 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
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 withconst [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
What is the difference between a ref and state?
Why is a timer id a good fit for a ref, and what goes wrong with a normal variable?
Why shouldn't you read or write ref.current during rendering? Is there an exception?
How does a component remember the previous value of a prop?
Practice
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.
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
useRefand read and changeref.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.