JavaScript and React course ยท Module 3: State and Events

Updating State - Patterns and Pitfalls

7 min read
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:

  1. Functional update (prev => prev + 1) - use when the new value depends on the previous one
  2. Never mutate state - always create new arrays/objects (spread operator)
  3. Batching - React groups updates and renders the component once
  4. Stale closures - in callbacks and timers, use functional updates
  5. Multiple useState vs object - depends on the logical relationship of the data
  6. 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. 1. Why should you use functional updates (prev => prev + 1) when calling setState multiple times in a single handler?

  2. 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:

Useful articles