JavaScript and React course ยท Module 3: State and Events
Updating State - Patterns and Pitfalls
In this lesson8
Imagine that the navigation systems of your spaceship operate based on state data - speed, course, fuel level. Every update to these parameters must be precise, because even a small error can throw the ship out of orbit. In React, state updates follow similar rules - you must know the patterns for safe updates so your application behaves predictably.
Functional update vs direct value
When you call setState, you have two options. The first is to pass a new value directly:
1setCount(count + 1);The second is a functional update - passing a function that receives the previous state value:
1setCount(prev => prev + 1);Why does this matter? React batches state updates (batching) and does not execute them immediately. If you call setCount(count + 1) multiple times in a single handler, each call sees the same value of count:
1function FuelGauge() {
2 const [fuel, setFuel] = useState(50);
3
4 const handleTripleBoost = () => {
5 // All three see the same value of fuel!
6 // Result: fuel will increase by only 10, not 30
7 setFuel(fuel + 10);
8 setFuel(fuel + 10);
9 setFuel(fuel + 10);
10 };
11
12 const handleTripleBoostCorrect = () => {
13 // Each call receives the current value
14 // Result: fuel will increase by 30
15 setFuel(prev => prev + 10);
16 setFuel(prev => prev + 10);
17 setFuel(prev => prev + 10);
18 };
19
20 return (
21 <div>
22 <p>Fuel: {fuel}%</p>
23 <button onClick={handleTripleBoost}>Boost (incorrect)</button>
24 <button onClick={handleTripleBoostCorrect}>Boost (correct)</button>
25 </div>
26 );
27}Rule: Always use functional updates (prev => ...) when the new value depends on the previous one.
Multiple state variables vs a single object
You have two strategies for storing state in a component. The first is separate state variables:
1function ShipDashboard() {
2 const [speed, setSpeed] = useState(0);
3 const [altitude, setAltitude] = useState(0);
4 const [heading, setHeading] = useState(0);
5
6 // Each variable updated independently
7 const accelerate = () => setSpeed(prev => prev + 10);
8 const climb = () => setAltitude(prev => prev + 100);
9
10 return (
11 <div>
12 <p>Speed: {speed} km/s | Altitude: {altitude} km | Heading: {heading}ยฐ</p>
13 <button onClick={accelerate}>Accelerate</button>
14 <button onClick={climb}>Climb</button>
15 </div>
16 );
17}The second is state in an object - useful when the data is related:
1function ShipDashboard() {
2 const [navigation, setNavigation] = useState({
3 speed: 0,
4 altitude: 0,
5 heading: 0
6 });
7
8 // IMPORTANT: always copy the entire object with the spread operator!
9 const accelerate = () => {
10 setNavigation(prev => ({ ...prev, speed: prev.speed + 10 }));
11 };
12
13 return (
14 <div>
15 <p>Speed: {navigation.speed} km/s</p>
16 <button onClick={accelerate}>Accelerate</button>
17 </div>
18 );
19}When to use which?
- Separate
useState- when values change independently of each other - Object in
useState- when values logically belong together (e.g., a form, x/y position)
Most common mistakes - state mutation
In React, state is immutable. Never modify state directly - always create a new copy. It is like a safety rule on a spaceship: you do not edit the ship's log, you just add a new entry.
Mistake 1: push instead of spread for arrays
1function MissionLog() {
2 const [logs, setLogs] = useState(["Mission started"]);
3
4 const addLog = (entry) => {
5 // MISTAKE: push mutates the existing array!
6 // React will not detect the change and will not re-render the component
7 logs.push(entry);
8 setLogs(logs);
9 };
10
11 const addLogCorrect = (entry) => {
12 // Spread creates a new array
13 setLogs(prev => [...prev, entry]);
14 };
15
16 return (
17 <div>
18 <button onClick={() => addLogCorrect("New entry")}>
19 Add Entry
20 </button>
21 {logs.map((log, i) => <p key={i}>{log}</p>)}
22 </div>
23 );
24}Mistake 2: directly changing object properties
1function CrewMember() {
2 const [member, setMember] = useState({
3 name: "Captain Nova",
4 rank: "Captain",
5 missions: 12
6 });
7
8 const promote = () => {
9 // MISTAKE: direct mutation of the object
10 member.rank = "Admiral";
11 setMember(member);
12 };
13
14 const promoteCorrect = () => {
15 // Creating a new object with the spread operator
16 setMember(prev => ({ ...prev, rank: "Admiral" }));
17 };
18
19 return (
20 <div>
21 <p>{member.name} - {member.rank}</p>
22 <button onClick={promoteCorrect}>Promote</button>
23 </div>
24 );
25}Stale closures - the closure trap
A closure is a situation where a function "remembers" variables from the moment it was created. In React, this can lead to reading outdated state:
1function CountdownTimer() {
2 const [seconds, setSeconds] = useState(10);
3 const [running, setRunning] = useState(false);
4
5 const startCountdown = () => {
6 // seconds is "frozen" from the moment of the click!
7 // Every interval sees the same value
8 setInterval(() => {
9 setSeconds(seconds - 1); // always 10 - 1 = 9
10 }, 1000);
11 };
12
13 const startCountdownCorrect = () => {
14 setRunning(true);
15 // Functional update always uses the current value
16 setInterval(() => {
17 setSeconds(prev => Math.max(prev - 1, 0));
18 }, 1000);
19 };
20
21 return (
22 <div>
23 <p>Countdown: {seconds}s</p>
24 <button onClick={startCountdownCorrect} disabled={running}>Start</button>
25 </div>
26 );
27}The button with disabled={running} will not let you start a second interval, and Math.max(prev - 1, 0) stops the counter at zero. The interval itself keeps running, though, until you stop it with clearInterval - you will learn proper timer cleanup in the module on effects (useEffect).
Batching - grouping updates
React 18+ automatically batches multiple state updates in a single event handler, performing only one re-render. It is like the onboard computer that collects all sensor reports and updates the display once, instead of refreshing it after each sensor:
1function NavigationUpdate() {
2 const [position, setPosition] = useState({ x: 0, y: 0 });
3 const [velocity, setVelocity] = useState(0);
4 const [status, setStatus] = useState("idle");
5
6 const handleJump = () => {
7 // React batches these 3 updates into one render
8 setPosition({ x: 100, y: 200 });
9 setVelocity(9999);
10 setStatus("hyperjump");
11 // The component renders ONCE, not three times!
12 };
13
14 return (
15 <div>
16 <p>Position: ({position.x}, {position.y})</p>
17 <p>Velocity: {velocity} | Status: {status}</p>
18 <button onClick={handleJump}>Hyperspace Jump</button>
19 </div>
20 );
21}Practical state update patterns
Here are some patterns that will come in handy on board the React ship:
1// Removing an element from an array
2setItems(prev => prev.filter(item => item.id !== idToRemove));
3
4// Updating one element in an array
5setItems(prev => prev.map(item =>
6 item.id === targetId ? { ...item, status: "done" } : item
7));
8
9// Toggle (switching a boolean)
10setIsActive(prev => !prev);
11
12// Adding an element to the beginning of an array
13setMessages(prev => [newMessage, ...prev]);
14
15// Incrementing with a limit
16setFuel(prev => Math.min(prev + 10, 100));Lazy state initialization
The initial value is only needed on the first render. But if you call a function inside the useState parentheses, it runs on every render, even though React uses its result only the first time:
1// WRONG - computeStarMap() runs on every render
2const [stars, setStars] = useState(computeStarMap());For an expensive computation (e.g. building a star map from thousands of points), pass the function itself - React calls it only once, on the first render:
1// CORRECT - the initializer function runs only on the first render
2const [stars, setStars] = useState(() => computeStarMap());You pass the function (() => computeStarMap() or just computeStarMap), not its result - React remembers the value and does not compute it again on later renders.
Summary
Updating state in React - key principles:
- Functional update (
prev => prev + 1) - use when the new value depends on the previous one - Never mutate state - always create new arrays/objects (spread operator)
- Batching - React groups updates and renders the component once
- Stale closures - in callbacks and timers, use functional updates
- Multiple useState vs object - depends on the logical relationship of the data
- Lazy initialization -
useState(() => compute())computes the initial value only once
Code for this lesson: App.jsx
1import React, { useState } from 'react';
2
3function FuelManager() {
4 // Separate state variables
5 const [fuel, setFuel] = useState(50);
6 const [oxygen, setOxygen] = useState(100);
7
8 // State as an object
9 const [position, setPosition] = useState({ x: 0, y: 0, z: 0 });
10
11 // State as an array
12 const [logs, setLogs] = useState([
13 { id: 1, message: 'System started', time: '00:00' }
14 ]);
15
16 const [newLog, setNewLog] = useState('');
17
18 // Functional update (prev =>)
19 const boostFuel = () => {
20 setFuel(prev => Math.min(prev + 10, 100));
21 };
22
23 const consumeFuel = () => {
24 setFuel(prev => Math.max(prev - 15, 0));
25 };
26
27 // Object update with spread
28 const moveShip = (axis, delta) => {
29 setPosition(prev => ({ ...prev, [axis]: prev[axis] + delta }));
30 };
31
32 // Adding to an array (spread, not push!)
33 const addLog = () => {
34 if (!newLog.trim()) return;
35 const now = new Date().toLocaleTimeString().slice(0, 5);
36 setLogs(prev => [...prev, {
37 id: Date.now(),
38 message: newLog,
39 time: now
40 }]);
41 setNewLog('');
42 };
43
44 // Removing from an array (filter)
45 const removeLog = (id) => {
46 setLogs(prev => prev.filter(log => log.id !== id));
47 };
48
49 // Triple boost - comparing the wrong and correct approach
50 const brokenTripleBoost = () => {
51 // fuel is "frozen" - all 3 see the same value
52 setFuel(fuel + 5);
53 setFuel(fuel + 5);
54 setFuel(fuel + 5);
55 };
56
57 const correctTripleBoost = () => {
58 // Each call sees the current value
59 setFuel(prev => Math.min(prev + 5, 100));
60 setFuel(prev => Math.min(prev + 5, 100));
61 setFuel(prev => Math.min(prev + 5, 100));
62 };
63
64 return (
65 <div style={{ fontFamily: 'Arial, sans-serif', padding: '20px', maxWidth: '700px', margin: '0 auto' }}>
66 <h1 style={{ textAlign: 'center', color: '#667eea' }}>State update patterns</h1>
67
68 {/* Fuel section */}
69 <div style={{ padding: '16px', marginBottom: '16px', backgroundColor: '#f0f4ff', borderRadius: '12px' }}>
70 <h3>Fuel: {fuel}%</h3>
71 <div style={{ height: '20px', backgroundColor: '#ddd', borderRadius: '10px', overflow: 'hidden' }}>
72 <div style={{
73 height: '100%', width: fuel + '%',
74 backgroundColor: fuel > 30 ? '#4caf50' : '#f44336',
75 transition: 'width 0.3s'
76 }} />
77 </div>
78 <div style={{ marginTop: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
79 <button onClick={boostFuel} style={btnStyle('#4caf50')}>+10 (functional)</button>
80 <button onClick={consumeFuel} style={btnStyle('#f44336')}>-15 (functional)</button>
81 <button onClick={brokenTripleBoost} style={btnStyle('#ff9800')}>3x +5 (wrong)</button>
82 <button onClick={correctTripleBoost} style={btnStyle('#2196f3')}>3x +5 (correct)</button>
83 </div>
84 </div>
85
86 {/* Position section (object) */}
87 <div style={{ padding: '16px', marginBottom: '16px', backgroundColor: '#f0fff4', borderRadius: '12px' }}>
88 <h3>Ship position (object with spread)</h3>
89 <p>X: {position.x} | Y: {position.y} | Z: {position.z}</p>
90 <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
91 <button onClick={() => moveShip('x', 10)} style={btnStyle('#667eea')}>X +10</button>
92 <button onClick={() => moveShip('y', 10)} style={btnStyle('#667eea')}>Y +10</button>
93 <button onClick={() => moveShip('z', 10)} style={btnStyle('#667eea')}>Z +10</button>
94 <button onClick={() => setPosition({ x: 0, y: 0, z: 0 })} style={btnStyle('#999')}>Reset</button>
95 </div>
96 </div>
97
98 {/* Logs section (array) */}
99 <div style={{ padding: '16px', backgroundColor: '#fff8f0', borderRadius: '12px' }}>
100 <h3>Ship log (array with spread)</h3>
101 <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
102 <input
103 value={newLog}
104 onChange={e => setNewLog(e.target.value)}
105 placeholder="New entry..."
106 style={{ flex: 1, padding: '8px', borderRadius: '8px', border: '1px solid #ddd' }}
107 />
108 <button onClick={addLog} style={btnStyle('#ff9800')}>Add</button>
109 </div>
110 {logs.map(log => (
111 <div key={log.id} style={{
112 display: 'flex', justifyContent: 'space-between', alignItems: 'center',
113 padding: '8px 12px', marginBottom: '6px', backgroundColor: '#fff',
114 borderRadius: '8px', border: '1px solid #eee'
115 }}>
116 <span><strong>[{log.time}]</strong> {log.message}</span>
117 <button onClick={() => removeLog(log.id)} style={{
118 background: 'none', border: 'none', color: '#f44336', cursor: 'pointer', fontSize: '16px'
119 }}>x</button>
120 </div>
121 ))}
122 </div>
123 </div>
124 );
125}
126
127function btnStyle(bg) {
128 return {
129 padding: '8px 16px', backgroundColor: bg, color: 'white',
130 border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '13px'
131 };
132}
133
134export default function App() {
135 return <FuelManager />;
136}Remember: State in React is like the ship's log - you never edit it, you just create a new entry based on the previous one!
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Why should you use functional updates (prev => prev + 1) when calling setState multiple times in a single handler?
2. Why is the following code incorrect? const [items, setItems] = useState(['A', 'B']); items.push('C'); setItems(items);
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange what happens when React batches multiple state updates: