JavaScript and React course Β· Module 5: useEffect and Custom Hooks
Storing Values Between Renders with useRef
In this lesson6
Picture this: you want to remember the mission timer's ID or the ship's previous speed. A plain variable in the component won't do, because on every render the component function runs from scratch and the variable goes back to its initial value. useState would remember the value, but every change to it would trigger a re-render, even though the screen doesn't need to change at all. We need something in between.
On our cosmic journey through React, sometimes we need to store information that persists between component renders but shouldn't cause a re-render. The useRef hook is like an escape pod - it stores data you can retrieve at any moment, regardless of how many times your spaceship (component) gets rebuilt.
What is useRef?
The useRef hook creates a mutable reference object whose .current property is initialized with the passed value. An important feature of useRef is that:
- The value stored in the reference remains the same between renders
- Changing the ref value does NOT cause the component to re-render
- Acts as a "memory store" that you can reference at any point in the component's lifecycle
The react.dev documentation adds two details. First, useRef returns the same object on every render, and the initial value is only used on the first render. Second, you change .current with a plain assignment, without any setter function.
useRef vs useState - key differences
Let's compare two key methods of storing data in components. In the panel below we keep the speed in state and the previous speed in a ref, which useEffect updates after every change:
1function SpaceshipDashboard() {
2 // State - changes cause re-rendering
3 const [speed, setSpeed] = useState(0);
4
5 // Reference - changes do NOT cause re-rendering
6 const previousSpeedRef = useRef(0);
7
8 // When speed changes, we save the previous value
9 useEffect(() => {
10 previousSpeedRef.current = speed;
11 }, [speed]);
12
13 const increaseSpeed = () => {
14 setSpeed(speed + 10); // This will cause a re-render
15 };
16
17 return (
18 <div>
19 <p>Current velocity: {speed} km/h</p>
20 <p>Previous speed: {previousSpeedRef.current} km/h</p>
21 <button onClick={increaseSpeed}>Increment speed</button>
22 </div>
23 );
24}A click changes the state, so the component renders again. During that render the ref still holds the old value, because the effect only runs after rendering. That is why you see the previous speed on screen. The assignment to previousSpeedRef.current in the effect doesn't refresh anything by itself.
An important caveat: react.dev recommends not reading or writing ref.current during rendering (lazy initialization is the exception). This pattern works in a simple panel, but it is fragile. If the previous value should be visible on screen, the documentation suggests keeping it in state. Leave refs for data the screen doesn't show.
Common use cases for useRef
1. Storing previous values
One of the most popular uses of useRef is remembering previous values between renders. The asteroid tracker calculates how far the object has moved since the last render:
1function AsteroidTracker({ asteroid }) {
2 const [position, setPosition] = useState({ x: 0, y: 0 });
3 const previousPositionRef = useRef({ x: 0, y: 0 });
4
5 useEffect(() => {
6 // We save the current position as previous before the next update
7 previousPositionRef.current = position;
8 }, [position]);
9
10 // We calculate how far the asteroid has moved
11 const distanceMoved = Math.sqrt(
12 Math.pow(position.x - previousPositionRef.current.x, 2) +
13 Math.pow(position.y - previousPositionRef.current.y, 2)
14 );
15
16 return (
17 <div>
18 <p>Current position: ({position.x}, {position.y})</p>
19 <p>Previous position: ({previousPositionRef.current.x}, {previousPositionRef.current.y})</p>
20 <p>Distance traveled: {distanceMoved}</p>
21 </div>
22 );
23}The mechanism is the same as in the speed panel: the effect saves the position after rendering, and the calculation uses the value from the previous render. The same caveat about reading refs during rendering applies here too.
2. Counters and instance values that don't affect rendering
This is where useRef really shines. Mission time and the interval ID are data that change every second but are not displayed live:
1function MissionControl() {
2 const missionTimeRef = useRef(0);
3 const timerIdRef = useRef(null);
4
5 // Starting mission timer
6 const startMissionTimer = () => {
7 if (timerIdRef.current !== null) return; // Avoiding multiple starts
8
9 timerIdRef.current = setInterval(() => {
10 missionTimeRef.current += 1;
11 console.log(`Mission time: ${missionTimeRef.current} seconds`);
12 // Note that this does NOT cause component re-rendering
13 }, 1000);
14 };
15
16 // Stopping mission timer
17 const stopMissionTimer = () => {
18 clearInterval(timerIdRef.current);
19 timerIdRef.current = null;
20 };
21
22 // Cleaning up on component unmount
23 useEffect(() => {
24 return () => {
25 if (timerIdRef.current !== null) {
26 clearInterval(timerIdRef.current);
27 }
28 };
29 }, []);
30
31 return (
32 <div>
33 <button onClick={startMissionTimer}>Start mission</button>
34 <button onClick={stopMissionTimer}>End mission</button>
35 <button onClick={() => alert(`Mission time: ${missionTimeRef.current} seconds`)}>
36 Show mission time
37 </button>
38 </div>
39 );
40}The counter grows every second, the console confirms it, and the component doesn't render even once. We read the refs only in event handlers and in the effect, exactly where the documentation recommends. The buttons and their appearance stay unchanged for the whole mission.
3. Accessing DOM elements
The third use is a handle to a real element on the page. You pass the ref in the ref attribute, and after rendering React stores the DOM node in .current:
1function SpaceshipScanner() {
2 const scannerInputRef = useRef(null);
3
4 const focusScannerInput = () => {
5 // Direct access to DOM element
6 scannerInputRef.current.focus();
7 };
8
9 return (
10 <div>
11 <input
12 ref={scannerInputRef}
13 type="text"
14 placeholder="Enter coordinates..."
15 />
16 <button onClick={focusScannerInput}>Activate scanner</button>
17 </div>
18 );
19}Clicking the button puts the cursor in the coordinates field. React doesn't create a new field here, it just gives you access to the existing one. In React 19 you can also pass ref as a regular prop to your own function component, without forwardRef.
useRef and useCallback
Both hooks "remember" something, but different things. useRef holds any value that you change yourself. useCallback memoizes a function and creates a new one only when one of its dependencies changes:
1const handleClick = useCallback(() => {
2 onClick(id);
3}, [id, onClick]);The order is always the same: the function, then the dependency array. As long as id and onClick don't change, handleClick stays the same function object.
Remember the rules for using useRef
- Don't overuse it: Use useRef only when the value truly shouldn't cause a re-render
- Avoid direct DOM mutations: Although useRef gives you access to DOM elements, use it sparingly and in accordance with React's philosophy
- Remember to clean up: If you use useRef to store timers or subscriptions, remember to clean them up in the useEffect cleanup function
My advice: before you reach for a ref, ask whether the value should be visible on screen. If yes, use state. If not, a ref will be lighter.
Summary
The useRef hook is invaluable when you need to:
- Store a value between renders without causing a re-render
- Track previous values
- Get direct access to DOM elements
- Store timer identifiers or other instance values
In the next lesson we will build our own hooks, in which refs often hide details such as timers.
Like an escape pod in space, useRef stores valuable data that may be needed at critical moments in your component's mission.
Code for this lesson: App.jsx
1import React, { useState, useRef, useEffect } from 'react';
2
3// Tracking previous values
4function PreviousValueTracker() {
5 const [position, setPosition] = useState({ x: 0, y: 0 });
6 const prevPositionRef = useRef({ x: 0, y: 0 });
7
8 useEffect(() => {
9 prevPositionRef.current = position;
10 });
11
12 const move = (dx, dy) => {
13 setPosition(prev => ({
14 x: Math.max(-100, Math.min(100, prev.x + dx)),
15 y: Math.max(-100, Math.min(100, prev.y + dy))
16 }));
17 };
18
19 return (
20 <div className="demo-card">
21 <h2>Position tracking</h2>
22 <div className="position-display">
23 <div className="current">
24 <h3>Current</h3>
25 <p>X: {position.x}</p>
26 <p>Y: {position.y}</p>
27 </div>
28 <div className="previous">
29 <h3>Previous</h3>
30 <p>X: {prevPositionRef.current.x}</p>
31 <p>Y: {prevPositionRef.current.y}</p>
32 </div>
33 </div>
34 <div className="controls">
35 <button onClick={() => move(0, -10)}>UP</button>
36 <div className="horizontal">
37 <button onClick={() => move(-10, 0)}>LEFT</button>
38 <button onClick={() => move(10, 0)}>RIGHT</button>
39 </div>
40 <button onClick={() => move(0, 10)}>DOWN</button>
41 </div>
42 </div>
43 );
44}
45
46// Render counter
47function RenderCounter() {
48 const renderCountRef = useRef(0);
49 const [value, setValue] = useState('');
50
51 renderCountRef.current++;
52
53 return (
54 <div className="demo-card">
55 <h2>Render counter</h2>
56 <div className="render-count">
57 Renders: <span>{renderCountRef.current}</span>
58 </div>
59 <input
60 value={value}
61 onChange={(e) => setValue(e.target.value)}
62 placeholder="Type to trigger a render..."
63 />
64 <p className="hint">
65 useRef stores a value without triggering a re-render
66 </p>
67 </div>
68 );
69}
70
71// Object comparison
72function ObjectComparisonDemo() {
73 const [user, setUser] = useState({ name: 'Astronauta', level: 1 });
74 const prevUserRef = useRef(user);
75 const [changes, setChanges] = useState([]);
76
77 useEffect(() => {
78 const changedFields = [];
79
80 if (prevUserRef.current.name !== user.name) {
81 changedFields.push(`name: "${prevUserRef.current.name}" -> "${user.name}"`);
82 }
83 if (prevUserRef.current.level !== user.level) {
84 changedFields.push(`level: ${prevUserRef.current.level} -> ${user.level}`);
85 }
86
87 if (changedFields.length > 0) {
88 setChanges(prev => [...prev.slice(-4), ...changedFields]);
89 }
90
91 prevUserRef.current = user;
92 }, [user]);
93
94 return (
95 <div className="demo-card">
96 <h2>Comparing changes</h2>
97 <div className="user-info">
98 <input
99 value={user.name}
100 onChange={(e) => setUser(prev => ({ ...prev, name: e.target.value }))}
101 placeholder="Name"
102 />
103 <div className="level-controls">
104 <button onClick={() => setUser(prev => ({ ...prev, level: Math.max(1, prev.level - 1) }))}>
105 -
106 </button>
107 <span>Level: {user.level}</span>
108 <button onClick={() => setUser(prev => ({ ...prev, level: prev.level + 1 }))}>
109 +
110 </button>
111 </div>
112 </div>
113 <div className="changes">
114 <h4>Change history:</h4>
115 {changes.map((change, i) => (
116 <div key={i} className="change">{change}</div>
117 ))}
118 </div>
119 </div>
120 );
121}
122
123// Caching computed values
124function ComputationCacheDemo() {
125 const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
126 const [multiplier, setMultiplier] = useState(2);
127 const cacheRef = useRef(new Map());
128 const cacheHits = useRef(0);
129 const cacheMisses = useRef(0);
130
131 const computeExpensive = (num, mult) => {
132 const key = `${num}-${mult}`;
133
134 if (cacheRef.current.has(key)) {
135 cacheHits.current += 1;
136 return cacheRef.current.get(key);
137 }
138
139 // Simulating an expensive operation
140 const result = num * mult;
141 cacheRef.current.set(key, result);
142 cacheMisses.current += 1;
143 return result;
144 };
145
146 const results = numbers.map(n => computeExpensive(n, multiplier));
147
148 const addNumber = () => {
149 setNumbers(prev => [...prev, prev.length + 1]);
150 };
151
152 return (
153 <div className="demo-card">
154 <h2>Cache with useRef</h2>
155 <div className="cache-stats">
156 <span className="hit">Hits: {cacheHits.current}</span>
157 <span className="miss">Misses: {cacheMisses.current}</span>
158 </div>
159 <div className="multiplier">
160 <button onClick={() => setMultiplier(m => Math.max(1, m - 1))}>-</button>
161 <span>Multiplier: {multiplier}</span>
162 <button onClick={() => setMultiplier(m => m + 1)}>+</button>
163 </div>
164 <div className="numbers">
165 {numbers.map((n, i) => (
166 <div key={i} className="number-result">
167 {n} x {multiplier} = {results[i]}
168 </div>
169 ))}
170 </div>
171 <button onClick={addNumber}>+ Add number</button>
172 </div>
173 );
174}
175
176function App() {
177 return (
178 <div className="app">
179 <h1>Storing values with useRef</h1>
180 <div className="demos">
181 <PreviousValueTracker />
182 <RenderCounter />
183 <ObjectComparisonDemo />
184 <ComputationCacheDemo />
185 </div>
186 </div>
187 );
188}
189
190export default App;Spotted a mistake in this lesson?
Hands-on tasks in the game
- Horizontal ordering
Arrange the correct useCallback syntax for an event handler: