Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 15 min read

Pitfalls: state & JSX

The mistakes everyone makes – usually more than once

In one sentence

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

Not like this
todos[0].done = true;
setTodos(todos);

user.address.city = "Berlin";
setUser(user);
Do this instead
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

stale.tsx
// 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
Pitfall The timer remembers the old value

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

drifts apart
function Price({ amount }) {
  const [value, setValue] = useState(amount);
  // if amount changes later, value stays put
  return <span>{value}</span>;
}
use the prop directly
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

Not like this
const [prices, setPrices] = useState([]);
const [total, setTotal] = useState(0);
const [empty, setEmpty] = useState(true);
Do this instead
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

keys.tsx
// 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 };
Pitfall How you notice it

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

renders a 0 on the page
{items.length && <List items={items} />}
{count && <Badge n={count} />}
an explicit condition
{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.

Tip Rule of thumb

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

Three bugs at once

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 0 appears 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 id as 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>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →