React Storing Mutable Values with useRef

In the last lesson, React Managing Focus with useRef, you used a ref to grab a DOM element. This lesson covers a ref’s second job: holding a value that changes over time, like a timer id, without ever showing it on screen or causing a re-render.

🤔 Why not just use state for everything?

Here is the pain. You reach for useState out of habit, even for values the screen never shows. And that causes real problems.

  • State has one special power: when it changes, React renders the component again.
  • That render is great when the value is on screen, like a counter the user reads.
  • But some values are private. The user never sees them. They are only there to help your code do its job.
  • A timer id is a good example. It is just a number React gives you so you can stop the timer later. Nobody reads it on screen.

So here is the trouble with putting a hidden value in state:

  • Every time it changes, React renders again for no reason.
  • The screen looks exactly the same after that render.
  • So that is wasted work, and it can even cause bugs.

📦 What “storing a mutable value” means

A ref is a small box that holds one value, and it survives across renders. You read and change that value through ref.current.

  • const idRef = useRef(null) makes the box, starting with null inside.
  • You read the value with idRef.current.
  • You change it by just assigning: idRef.current = 42.
  • Changing .current does NOT trigger a render. That is the whole point.

Think of it like a sticky note on your desk. You can scribble a new number on it whenever you want. The room does not rearrange itself just because you changed the note. The note quietly remembers the value for you. That is a mutable value that lives outside of rendering.

import { useRef } from "react";
function Example() {
const idRef = useRef(null);
function remember() {
idRef.current = 123; // changing this does NOT cause a re-render
console.log(idRef.current); // reads back 123
}
return <button onClick={remember}>Remember a value</button>;
}

Here is what runs:

  • idRef.current starts as null, then clicking the button writes 123 into the box.
  • The component never renders again because of that change.
  • So the value is just sitting there, ready when you need it.

⚖️ Ref or state? The one rule to remember

This is the most important idea in the lesson, so let’s make the choice simple. Ask one question: does the value need to show on screen?

  • If the user should SEE the value, and the screen should update when it changes, use state.
  • If the value is only for your code in the background, and changing it should NOT update the screen, use a ref.

A quick way to sort the common cases:

You want to… Use Why
Show a count of seconds State The number is on screen, so it must re-render
Remember a timer id to stop it later Ref The id is never shown; no render needed
Keep the previous value of a prop Ref It is a private note for comparison, not UI
Flag “have we already run setup?” Ref The flag is behind the scenes, not on screen

A simple test

Read the value out loud. If it belongs on the page, it is state. If it is just a note your code keeps for itself, it is a ref.

⏱️ Storing a timer id: the classic case

The timer id is where this clicks for most people:

  • When you call setInterval, it hands you back an id.
  • You need to keep that id somewhere so you can stop the timer later with clearInterval.
  • So where should it live? This pair shows the wrong place and the right place for it.
// ❌ Wrong - timer id in state, so every start causes a needless re-render
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [intervalId, setIntervalId] = useState(null);
function start() {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
setIntervalId(id); // re-renders the component, but the id is never shown!
}
}
// ✅ Right - timer id in a ref, no extra render, value still remembered
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
intervalRef.current = id; // remembered, but no re-render
}
}

Let’s compare the two.

  • In the ❌ version, setIntervalId(id) puts the id in state. The user never sees this id anywhere. But React still renders the whole component again, just to store a private number.
  • In the ✅ version, intervalRef.current = id quietly saves the id in the box. No render. The id is still there waiting when you want to stop the timer.
  • Both remember the id. Only the ref does it without the pointless render.

The seconds still use state

Notice seconds stays in state in both versions. That is correct, because the seconds ARE shown on screen and must re-render to update. Only the hidden timer id moves to a ref.

🛑 Building a start/stop stopwatch

Now let’s put it together into a real stopwatch. Here is how the two values split up:

  • The displayed seconds live in state, because the user reads them.
  • The interval id lives in a ref, because it is the private handle we need to stop the timer.

Here is the full component, then we’ll walk through it.

import { useState, useRef } from "react";
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
function start() {
if (intervalRef.current !== null) return; // already running, do nothing
intervalRef.current = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}

Let’s read through what is happening.

  • seconds is state, so each tick calls setSeconds and the number on screen goes up.
  • intervalRef is the box that holds the timer id. It starts as null, which means “no timer running right now”.
  • In start, we first check intervalRef.current !== null. If a timer is already going, we stop and do nothing, so a double click can’t start two timers.
  • We save the id with intervalRef.current = setInterval(...). This does NOT cause a render, so starting the clock stays clean.
  • In stop, we call clearInterval(intervalRef.current) to halt the timer, then set the ref back to null so start knows it is free to run again.

What the user sees

Seconds: 0
[Start] [Stop]
(click Start, the number climbs once per second)
Seconds: 5
(click Stop, it freezes)
Seconds: 5

The key takeaway: the only thing that re-renders is seconds. Starting and stopping never causes an extra render, because the id is tucked away in the ref.

🕰️ Remembering the previous value

A ref is also the clean way to remember what a value was on the last render. State can’t do this easily, because reading state always gives you the current value, not the old one.

Here we keep the previous count in a ref and compare it to the current count.

import { useState, useRef, useEffect } from "react";
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef(0);
useEffect(() => {
prevCountRef.current = count; // remember this render's count for next time
}, [count]);
const prev = prevCountRef.current;
return (
<div>
<p>Now: {count}, before: {prev}</p>
<button onClick={() => setCount(count + 1)}>Add</button>
</div>
);
}

Here is the order of events.

  • During render we read prevCountRef.current, which still holds the value from the last render. So prev is the old count.
  • After the render, useEffect runs and updates the ref to the current count.
  • So on the next render, that updated value becomes “before” again.
  • The ref is perfect here because remembering the old value should not, by itself, cause another render. It is just a private note.

Don't read a ref to decide what to show, mid-render

Reading a ref during render is fine for display only, like showing the previous value above. But do not change a ref during render to drive what the UI looks like. If a value decides what the screen shows, that value belongs in state.

🚩 A behind-the-scenes flag

The last common use is a small flag that your code checks but the user never sees. Say you want some setup to run only the first time, and never again.

This flag lives in a ref because flipping it should not re-render anything.

import { useRef, useEffect } from "react";
function Greeting() {
const hasGreetedRef = useRef(false);
useEffect(() => {
if (hasGreetedRef.current) return; // already done, skip
console.log("Welcome! (runs only once)");
hasGreetedRef.current = true; // flip the flag, no re-render
});
return <p>Hello there.</p>;
}

Let’s see why a ref fits here.

  • hasGreetedRef starts as false, meaning “we have not greeted yet”.
  • The first time the effect runs, the flag is false, so the greeting runs.
  • We flip the flag to true. This is a private note, so flipping it causes no render.
  • Every later run sees true and skips the greeting.
  • A state flag would work too, but it would render the component again just to record something the user never sees. The ref avoids that.

🧩 What You’ve Learned

  • ✅ A ref is a box that holds a mutable value across renders, read and written through ref.current
  • ✅ Changing ref.current does NOT cause a re-render, which is the whole reason to use it
  • ✅ Use state when the value should show on screen; use a ref when it is a behind-the-scenes value
  • ✅ Store a timer id in a ref so you can call clearInterval later without a needless render
  • ✅ Putting a hidden value like a timer id in state causes wasted re-renders
  • ✅ A start/stop stopwatch keeps the seconds in state and the interval id in a ref
  • ✅ A ref is the clean way to remember the previous value of a prop or state
  • ✅ A ref also works for a private flag that your code checks but the user never sees

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    What happens when you change ref.current?

    Why: Writing to ref.current quietly stores the value across renders. Unlike state, it does not trigger a re-render, which is exactly why it suits behind-the-scenes values.

  2. 2

    You need to remember a setInterval id so you can stop it later. Where should it go?

    Why: The id is never shown on screen, so storing it in state would cause needless re-renders. A ref remembers it across renders without rendering again.

  3. 3

    Which value clearly belongs in state, not a ref?

    Why: The seconds are displayed to the user and the screen must update when they change, so they need state. The other three are private values the user never sees, which fit a ref.

  4. 4

    Why is a ref a good fit for storing the previous value of a prop or state?

    Why: Keeping a private note of the last value is a background job. A ref survives across renders and changing it does not render again, so it is the clean place for it.

🚀 What’s Next?

Now you can hold a timer id, a previous value, or a private flag in a ref without ever causing an extra render. Next you’ll learn how to pass a ref from a parent component down into a child, so the parent can reach an element the child renders.

React Forwarding Refs

Share & Connect