Skip to content

Fix: React useState not updating (state not changing after setState)

FixDevs ·

Quick Answer

How to fix React useState not updating. Covers async state updates, functional updates, object mutations, stale closures, setTimeout issues, React 18 batching, props initialization, and debugging state changes.

The Error

You call setState in a React component, but the state does not change. You log it right after the call and see the old value:

const [count, setCount] = useState(0);

function handleClick() {
  setCount(count + 1);
  console.log(count); // Still 0
}

Or you update an object in state, but the component does not re-render:

const [user, setUser] = useState({ name: "Alice", age: 25 });

function updateAge() {
  user.age = 26;
  setUser(user); // Component does not re-render
}

The state appears stuck. Your UI shows stale data, and no amount of clicking seems to fix it. This is one of the most common React bugs, and it has several distinct causes.

Why This Happens

React state updates do not work like regular variable assignments. There are three core reasons your state might not update as expected:

  1. State updates are asynchronous. When you call setCount(1), React does not immediately change the value of count. It schedules a re-render. The current execution context still holds the old value.

  2. React uses referential equality for objects and arrays. If you mutate an object and pass the same reference to setState, React sees the same reference and skips the re-render entirely. It thinks nothing changed.

  3. Closures capture stale values. Functions defined inside your component capture the state value at the time they were created. If that function runs later (in a setTimeout, setInterval, or event listener), it uses the old captured value, not the current one.

Understanding these three principles explains nearly every “useState not updating” bug you will encounter. The fixes below address each scenario.

Fix 1: Stop Reading State Right After Setting It

This is the most common mistake. You call setState and immediately try to use the new value:

const [count, setCount] = useState(0);

function handleClick() {
  setCount(1);
  console.log(count); // 0 — not 1
  sendToAPI(count);   // Sends 0, not 1
}

setCount does not change count synchronously. The variable count is a const that holds the value from the current render. It cannot change mid-function.

The fix: Use the value you are setting, not the state variable:

function handleClick() {
  const newCount = count + 1;
  setCount(newCount);
  console.log(newCount); // 1
  sendToAPI(newCount);   // Sends 1
}

If you need to react to state changes, use useEffect:

useEffect(() => {
  console.log("Count changed to:", count);
  sendToAPI(count);
}, [count]);

Be careful with useEffect dependencies. If you list them incorrectly, you can trigger infinite loops in useEffect or get warnings about missing dependencies.

Fix 2: Use Functional Updates for State Based on Previous Value

When you update state based on the current state, using the state variable directly can cause bugs:

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // count ends up as 1, not 3
}

All three calls read the same count value (0), so all three schedule setCount(1). The last one wins, and you get 1 instead of 3.

The fix: Pass a function to setState. React calls it with the latest pending state:

function handleTripleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  // count ends up as 3
}

Each updater function receives the result of the previous one. This guarantees sequential updates work correctly.

Common Mistake: Every time your new state depends on the old state, use the functional form. Writing setCount(count + 1) is only safe when you call it once per render cycle and do not care about batched updates.

Use functional updates for toggles, counters, array pushes, and any accumulation pattern:

// Toggle
setOpen(prev => !prev);

// Append to array
setItems(prev => [...prev, newItem]);

// Remove from array
setItems(prev => prev.filter(item => item.id !== id));

Fix 3: Fix Object and Array State Mutations

This is the second most common cause. You mutate the object directly instead of creating a new one:

const [user, setUser] = useState({ name: "Alice", age: 25 });

// WRONG — mutates the existing object
function updateAge() {
  user.age = 26;
  setUser(user); // Same reference, React skips re-render
}

React compares state with Object.is(). Since user is the same object reference before and after the mutation, React concludes nothing changed.

The fix: Create a new object with the spread operator:

function updateAge() {
  setUser({ ...user, age: 26 }); // New object, React re-renders
}

For nested objects, you need to spread at every level:

const [state, setState] = useState({
  user: {
    profile: {
      name: "Alice",
      settings: { theme: "dark" }
    }
  }
});

// Update nested property
setState(prev => ({
  ...prev,
  user: {
    ...prev.user,
    profile: {
      ...prev.user.profile,
      settings: {
        ...prev.user.profile.settings,
        theme: "light"
      }
    }
  }
}));

For deeply nested updates, use structuredClone to make a deep copy first:

setState(prev => {
  const next = structuredClone(prev);
  next.user.profile.settings.theme = "light";
  return next;
});

Or use a library like Immer that lets you write mutation-style code that produces immutable updates under the hood.

The same rules apply to arrays. Never use push, pop, splice, or direct index assignment on state arrays:

// WRONG
items.push(newItem);
setItems(items);

// CORRECT
setItems(prev => [...prev, newItem]);

// CORRECT — removing an item
setItems(prev => prev.filter(item => item.id !== targetId));

// CORRECT — updating an item
setItems(prev => prev.map(item =>
  item.id === targetId ? { ...item, done: true } : item
));

Fix 4: Fix Stale Closures in useEffect and Event Handlers

A stale closure happens when a function captures an old value of state and keeps using it:

const [count, setCount] = useState(0);

useEffect(() => {
  document.addEventListener("click", () => {
    console.log(count); // Always 0, even after state changes
  });
}, []); // Empty dependency array — captures count = 0 forever

The event handler closes over the initial count value. Because the effect runs only once (empty dependency array), it never gets a fresh reference.

The fix: Include the state variable in the dependency array and clean up the listener:

useEffect(() => {
  function handleClick() {
    console.log(count); // Current value on each re-run
  }
  document.addEventListener("click", handleClick);
  return () => document.removeEventListener("click", handleClick);
}, [count]);

Alternatively, use a ref to always access the latest value without re-running the effect:

const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  document.addEventListener("click", () => {
    console.log(countRef.current); // Always current
  });
  // cleanup...
}, []);

Stale closures are a frequent source of bugs when working with hooks called in the wrong order or effects with incorrect dependency arrays.

Fix 5: Fix State Not Updating in setTimeout and setInterval

setTimeout and setInterval callbacks capture the state value at the time they are created:

const [count, setCount] = useState(0);

function handleClick() {
  setTimeout(() => {
    setCount(count + 1); // Always sets to 1, regardless of current count
  }, 3000);
}

If you click the button five times quickly, all five timeouts capture count = 0 and all set state to 1.

The fix: Use functional updates:

function handleClick() {
  setTimeout(() => {
    setCount(prev => prev + 1); // Correct — uses latest state
  }, 3000);
}

For setInterval, the problem is even worse because the callback runs repeatedly with the same stale value. The standard pattern is:

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // Functional update avoids stale closure
  }, 1000);
  return () => clearInterval(id);
}, []);

Pro Tip: Whenever you pass a state updater into any async context (setTimeout, setInterval, Promise .then, async/await), default to the functional form prev => .... It eliminates an entire category of bugs with zero downside.

If you need to read (not just update) the latest state inside an interval, use a ref as shown in Fix 4.

Fix 6: Understand React 18 Automatic Batching

Before React 18, state updates inside setTimeout, fetch callbacks, and native event listeners were not batched. Each setState call triggered a separate re-render. In React 18, all state updates are batched automatically, regardless of where they originate.

This means multiple setState calls in the same synchronous block produce only one re-render:

function handleClick() {
  setCount(count + 1);
  setFlag(true);
  setName("Alice");
  // One re-render, not three
}

This is usually a good thing for performance. But it can surprise you if you expect intermediate renders between state updates.

When this causes problems: If you need to force a synchronous re-render between state updates (rare), use flushSync from react-dom:

import { flushSync } from "react-dom";

function handleClick() {
  flushSync(() => {
    setCount(count + 1);
  });
  // DOM is updated here
  flushSync(() => {
    setFlag(true);
  });
  // DOM is updated again
}

Warning: flushSync should be a last resort. It opts out of batching and forces synchronous re-renders, which hurts performance. Most of the time, batching is what you want.

If you see state “not updating” between consecutive calls, batching is likely the reason. Your state does update, but you only see the final result after all updates in the batch are processed. This ties back to Fix 1 — do not try to read state between setState calls.

Fix 7: Fix State Initialization from Props (Use key to Reset)

When you initialize state from a prop, the state only takes the prop value on the first render:

function Editor({ initialText }) {
  const [text, setText] = useState(initialText);
  // If parent passes a new initialText, text does NOT update
}

This is by design. useState reads its argument only once. Subsequent renders ignore it.

Fix option 1: Use a key prop on the component to force React to unmount and remount it:

// Parent component
<Editor key={documentId} initialText={doc.text} />

When documentId changes, React destroys the old Editor and creates a new one. The new instance reads the fresh initialText. This is the cleanest solution when you want a full reset.

Fix option 2: Sync the prop into state with useEffect:

function Editor({ initialText }) {
  const [text, setText] = useState(initialText);

  useEffect(() => {
    setText(initialText);
  }, [initialText]);
}

This works but has a subtle issue: it renders once with the old value, then immediately re-renders with the new value. For most cases, the key approach is better.

Fix option 3: Remove the state entirely if the component does not need to modify the value:

function Editor({ text, onTextChange }) {
  // Controlled component — no internal state
  return <textarea value={text} onChange={e => onTextChange(e.target.value)} />;
}

This pattern, known as “lifting state up,” avoids the syncing problem entirely. It also prevents issues like updating state during render.

Fix 8: Debug State Changes with useEffect

When state seems stuck and you cannot figure out why, add a dedicated useEffect to trace changes:

const [count, setCount] = useState(0);

useEffect(() => {
  console.log("count changed:", count);
}, [count]);

This fires after every render where count actually changed. If you never see the log, state is truly not updating. If you see it but your UI does not reflect it, the problem is in your rendering logic, not your state.

For more complex debugging, log the previous and current value:

const prevCountRef = useRef(count);

useEffect(() => {
  console.log(`count: ${prevCountRef.current} → ${count}`);
  prevCountRef.current = count;
}, [count]);

You can also use React DevTools to inspect component state in real time. Open the Components tab, select your component, and watch the state values in the right panel. This is faster than adding console logs for quick checks.

If your debugging reveals that state updates are happening but the wrong component is reading the state, you may have a component hierarchy issue. Check that you are not accidentally rendering too many re-renders by passing state down incorrectly.

Still Not Working?

If none of the fixes above solve your problem, check these less obvious causes:

State vs. Ref Confusion

If you store a value in useRef instead of useState, changing .current does not trigger a re-render:

const count = useRef(0);

function handleClick() {
  count.current += 1;
  // Component does NOT re-render — refs are silent
}

Refs are for values you need to persist across renders without causing re-renders (DOM references, timer IDs, previous values). If you need the UI to reflect a value, use useState.

Redux or Context State Not Triggering Re-render

If you use Redux, Zustand, or React Context, your component only re-renders when it is subscribed to the specific slice of state that changed.

For Redux with useSelector:

// This only re-renders when state.user changes (by reference)
const user = useSelector(state => state.user);

If the reducer mutates the existing object instead of returning a new one, useSelector sees the same reference and skips the re-render. Ensure your reducers return new objects, just like with useState.

For Context, any component consuming the context re-renders when the context value changes. If the context value is an object created inline in the provider, every render of the provider creates a new object and forces all consumers to re-render. Memoize the context value with useMemo:

const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;

DevTools Showing Stale State

React DevTools sometimes displays stale state if you are inspecting a component that has been hot-reloaded or if the DevTools panel was opened after the state changed. Try these steps:

  1. Close and reopen the DevTools Components panel.
  2. Click the component again to refresh the state display.
  3. Trigger a state update while watching the panel to see it change in real time.
  4. If using Strict Mode in development, remember that components render twice. The first render’s state may flash briefly in the console.

If DevTools shows the correct state but your UI does not, the problem is in your JSX — you may be rendering a different variable, a memoized value, or a prop instead of the state.

Key Takeaways

Every useState bug falls into one of these categories: reading state too early, mutating instead of replacing, or closures capturing stale values. Train yourself to default to functional updates (prev => ...) and immutable patterns (spread, map, filter). These two habits alone prevent the majority of state update issues in React applications.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles