Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 004 / 100

useState pitfalls: a React & Next.js guide for developers

Small mistakes that cause real bugs.

Expona Admin7 min read

After four days building with React, you've probably felt confident with useState. But confidence and correctness aren't the same thing. useState pitfalls are subtle bugs that slip into production because they don't fail loudly—they just produce the wrong value at the wrong time. I've debugged these in real transaction systems and ride-hailing dashboards, and they always follow the same patterns.

Stale Closures: When Your Updates Use Yesterday's Values

The most common useState pitfall I encounter is stale closures. This happens when you reference a state variable inside an async function or timeout, and the function captures an old copy of that variable instead of the current one.

Here's a real scenario from a payment system I built:

tsx
const [paymentAttempts, setPaymentAttempts] = useState(0);

const handleRetry = () => {
  // This closure captures paymentAttempts at call time
  setTimeout(() => {
    console.log('Current attempts:', paymentAttempts); // Stale value!
    setPaymentAttempts(paymentAttempts + 1);
  }, 2000);
};

If you call handleRetry three times rapidly, the first timeout will log 0, the second will log 0 again, and the third will also log 0. All three increments will set the count to 1 instead of 3. The setTimeout captured paymentAttempts when each function was created, not when it executed.

This also happens in event handlers within loops, API callbacks, and event listeners. Anywhere the state variable is read inside a function that runs later, you're at risk.

The solution is to use the functional update form of setState. Instead of passing a value, pass a function that receives the current state and returns the new state:

tsx
const handleRetry = () => {
  setTimeout(() => {
    // Correct: React always passes the current state
    setPaymentAttempts(prev => {
      console.log('Current attempts:', prev); // Always fresh
      return prev + 1;
    });
  }, 2000);
};

Now React guarantees you're working with the latest value, even if multiple updates are queued. The function is called at execution time with the current state, not the stale closure.

Functional Updates: The Safe Pattern for Dependent State

Functional updates solve more than just async timing issues. They're the right pattern whenever your next state depends on the previous state.

Let me show you a toggle with a mistake I see often:

tsx
// Danger zone
const toggleModal = () => {
  setIsOpen(isOpen ? false : true);
};

// Even safer
const toggleModal = () => {
  setIsOpen(prev => !prev);
};

The first version works in simple cases, but fails when batching causes multiple updates. In React 18, if two toggleModal calls happen before a render, the second call still reads the old isOpen value. The second update overwrites the first. With the functional form, each update is based on the actual current state at that moment in the queue.

In a real e-commerce cart I worked on, this pattern became critical when syncing optimistic UI updates with server responses:

tsx
const [cart, setCart] = useState({ items: [], total: 0 });

const addItemOptimistic = (item) => {
  // Optimistic update immediately
  setCart(prev => ({
    ...prev,
    items: [...prev.items, item],
    total: prev.total + item.price
  }));

  // Server sync in background
  api.addToCart(item).catch(() => {
    // Rollback only removes what we optimistically added
    setCart(prev => ({
      ...prev,
      items: prev.items.filter(i => i.id !== item.id),
      total: prev.total - item.price
    }));
  });
};

Each setState is based on the previous cart state. If the user adds two items quickly, the second update doesn't lose the first item because it reads from the already-updated cart. This is predictable and composable.

Objects Are Replaced, Not Merged: The Shape of State Matters

React doesn't do shallow merging like some older frameworks. When you call setState with an object, it completely replaces the state. Any properties you don't include are lost.

A CRM dashboard I built had this bug:

tsx
const [user, setUser] = useState({
  name: 'Alice',
  email: 'alice@example.com',
  isActive: true
});

const updateEmail = (newEmail) => {
  // WRONG: This wipes out name and isActive
  setUser({ email: newEmail });
};

const updateEmail = (newEmail) => {
  // CORRECT: Spread the previous object
  setUser(prev => ({
    ...prev,
    email: newEmail
  }));
};

After calling updateEmail, the user object would only have email. The name and isActive are gone. This breaks rendering and causes undefined errors downstream.

You have two patterns to fix this. First, spread the previous state (shown above). Second, if state gets too nested or complex, split it into separate state variables:

tsx
// Instead of one object with many properties
const [userName, setUserName] = useState('Alice');
const [userEmail, setUserEmail] = useState('alice@example.com');
const [isActive, setIsActive] = useState(true);

// Now updates are simple and won't lose data
const updateEmail = (newEmail) => {
  setUserEmail(newEmail); // No other properties affected
};

This is simpler for one-off updates, but creates more state variables. In a ride-hailing app, I used the spread pattern for driver objects because they change together during a trip, but split form inputs into separate variables because they're independent.

Batching: Multiple setState Calls, One Render

React 18 changed how updates work. In event handlers and effects, React now automatically batches multiple setState calls into a single render. This is usually faster, but it surprises developers who expect synchronous updates.

tsx
const handleCheckout = () => {
  setCart([]);           // Queued
  setTotal(0);           // Queued
  setStatus('completed'); // Queued
  
  // React batches these three into ONE render pass
  // Component only re-renders once with all three updates applied
};

Without batching in older React, this would render three times. Now it renders once. The benefit is performance. The risk is that if you log state synchronously after setState, you'll see stale values:

tsx
const handleCheckout = () => {
  setCart([]);
  console.log(cart); // Still has items! Stale
};

State updates in React are not synchronous. The console.log runs before the component re-renders. If you need to react to state changes, use useEffect instead.

One more edge case: asynchronous code doesn't get batched by default. If you call setState inside a Promise or after an API call, React 18 still batches it automatically in most cases, but older behavior still applies in event listeners and other non-React contexts:

tsx
const handleAsyncSubmit = async () => {
  const response = await api.submit(data);
  
  // React 18: This is still batched (auto-batching)
  setLoading(false);
  setResult(response);
  // Single re-render
};

const handleManualAsync = () => {
  fetchData().then(data => {
    // React 18: Also batched
    setLoading(false);
    setData(data);
  });
};

In a trading platform I maintained, the batching behavior actually helped performance. We could batch a market data update, position update, and P&L recalculation all at once instead of flickering through intermediate states.

Common Mistakes

  • Reading state in effects without dependencies: Use useEffect(() => { ... }, [dependency]) to run code when state changes. Don't read stale state outside effects.
  • Mutating state directly: Never do state.items.push(...). Always create new arrays or objects with spread or array methods.
  • Forgetting the dependency array: useEffect(fn) runs after every render and can create infinite loops with setState. Use useEffect(fn, [deps]).
  • Updating state based on user input without using functional updates: Forms with rapid changes (like autocomplete) need setState(prev => ...) to ensure each keystroke is counted.
  • Not spreading nested objects: If you have setState({ user: {...} }), make sure to spread the nested object too: setState(prev => ({ ...prev, user: { ...prev.user, email } })).

Key Takeaways

  • Use the functional form of setState (setState(prev => ...)) whenever your next state depends on the previous state.
  • Stale closures happen in timeouts, promises, and async code. Functional updates prevent them.
  • Objects in state are replaced, not merged. Always spread the previous state or split into separate variables.
  • React 18 batches multiple setState calls, so don't expect synchronous updates. Use effects to react to state changes.
  • Test state updates with rapid calls or async flows. These are where useState pitfalls hide.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles