Pitfalls: state & JSX
The mistakes everyone makes – usually more than once
Seven traps around state and JSX, each with the thinking error behind it – so you recognise them in your own code.
Practically everyone makes these seven mistakes – most of them repeatedly. Once you have seen them, you spot them later in seconds instead of hours.
1. Changing the existing thing instead of replacing it
todos[0].done = true;
setTodos(todos);
user.address.city = "Berlin";
setUser(user); setTodos((p) =>
p.map((t, i) => (i === 0 ? { ...t, done: true } : t))
);
setUser((u) => ({
...u,
address: { ...u.address, city: "Berlin" },
})); Why does this happen? In ordinary JavaScript you simply change objects (MutationChanging an existing object or array in place instead of creating a new one – with push(), for example.→) – it is a habit. React, though, only checks whether it is still the same object (ReferenceFor objects and arrays JavaScript stores an address, not the contents. Two addresses differ even when the contents look identical.That is why { a: 1 } === { a: 1 } is false in JavaScript.→). With deeply nested data the copying gets tedious; then Immer or a flatter structure pays off.
2. Computing on with the frozen value
// After the click it says 1, not 3:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// And the classic in a timer – counts exactly once:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count is 0 in here forever The function was created once – and has held on to that moment's count ever since (the term is Stale closureA function works with outdated values because it was created in an earlier pass and holds on to the old ones.→). The fix: setCount((c) => c + 1). Then you do not need the outside value at all.
3. Copying props into state
function Price({ amount }) {
const [value, setValue] = useState(amount);
// if amount changes later, value stays put
return <span>{value}</span>;
} function Price({ amount }) {
return <span>{amount}</span>;
} The starting value is only used on the first pass. If you genuinely need an editable draft (a form field with a preset), name it that way: const [draft, setDraft] = useState(amount) – and reset it via key when a different record arrives.
4. Storing computable things twice
const [prices, setPrices] = useState([]);
const [total, setTotal] = useState(0);
const [empty, setEmpty] = useState(true); const [prices, setPrices] = useState([]);
const total = prices.reduce((a, b) => a + b, 0);
const empty = prices.length === 0; Every extra state variable is one more place where someone can forget to update. Ask: can I compute this? Then it is not state.
5. The index as a key
// Trouble as soon as anything is sorted, filtered, inserted or deleted:
{items.map((item, i) => <Row key={i} item={item} />)}
// Right: an identity taken from the data
{items.map((item) => <Row key={item.id} item={item} />)}
// No id available? Hand one out when you create the record:
const item = { id: crypto.randomUUID(), text }; After deleting, checkboxes are ticked on the wrong rows, inputs keep somebody else's text, animations play on the wrong element. All the same mistake.
6. `&&` with numbers
{items.length && <List items={items} />}
{count && <Badge n={count} />} {items.length > 0 && <List items={items} />}
{count ? <Badge n={count} /> : null} 0 && x evaluates to 0 – and React dutifully renders the number 0 on screen. With false, null and undefined nothing happens; with 0 it does.
7. State too high up (or too far down)
State belongs to the nearest shared parent of everything that needs it – not higher. If the search text lives in App, every key press redraws the whole application. Too far down and the siblings cannot reach it.
As deep as possible, as high as necessary. If state has to move up, first consider whether the component could move down instead.
Does it stick?
6 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
This component has three typical mistakes: a derived value held twice (and forgotten on delete), the && problem with 0, and the index as a key. Clean them up.
- □With an empty list no
0appears on the page - □After adding and deleting, the count is still right
- □The second piece of state disappears – the count gets computed
- □The entries use their
idas a key instead of the index
import { useState } from "react"; type Item = { id: number; text: string }; let nextId = 1; export default function App() { const [items, setItems] = useState<Item[]>([]); const [count, setCount] = useState(0); // Bug 1: derived, but stored twice function add() { const id = nextId++; setItems((prev) => [...prev, { id, text: "Entry " + id }]); setCount(count + 1); } function remove(id: number) { setItems((prev) => prev.filter((i) => i.id !== id)); // the counting was forgotten here – and that is exactly the point } return ( <div style={{ fontFamily: "system-ui", padding: 16 }}> <button onClick={add}>Add</button> <p data-testid="count">Count: {count}</p> {/* Bug 2: renders a 0 when the list is empty */} {items.length && ( <ul> {/* Bug 3: the index as a key */} {items.map((item, i) => ( <li key={i}> {item.text}{" "} <button aria-label={"remove-" + item.id} onClick={() => remove(item.id)}> × </button> </li> ))} </ul> )} </div> ); }