Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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.

How Other Tools Handle This

The “I set the value, why didn’t anything update” problem exists in every reactive framework, but each one resolves it through a different model. Knowing the contrast helps you both diagnose React-specific bugs and avoid carrying false assumptions in from another framework.

React useState (snapshot model). Each render is a snapshot. count inside a function body is a const bound to the value at render time, and setCount schedules a re-render with a new value. There is no proxy and no reactive tracking — React detects change by referential comparison (Object.is) on the setter argument. This is why mutating an object and passing the same reference is a no-op: the snapshot reference is identical.

Vue 3 ref and reactive (proxy model). Vue wraps your data in a Proxy (for reactive) or in a RefImpl object with a .value getter/setter. Mutating a property on a reactive object triggers an update because the proxy traps the write. The trade-off is ref requires .value access inside script (count.value++), and proxying arrays of large size has runtime cost. Vue’s “not updating” bug class is usually losing reactivity by destructuring a reactive object — covered separately, but the root cause is unwrapping the proxy.

Solid signals (compile-time tracked). Solid uses signals (createSignal) and the compiler rewrites JSX so each reactive read registers a subscription at the call site. There is no re-render of the component function — only the specific reactive expression updates. Solid does not need batching configuration because updates are scheduled into microtasks by the runtime. Stale-closure bugs are far rarer because the component function runs once per mount.

Svelte 5 $state runes. Svelte 5 replaced its previous compiler-magic reactivity with explicit $state runes that produce reactive variables. Assignment (count = count + 1) triggers tracked updates. Like Solid, the component setup runs once and reactivity is fine-grained, so the React snapshot trap does not exist. The new bug class is forgetting to wrap nested objects with $state deeply.

Qwik signals (resumable). Qwik uses signals like Solid but adds resumability: the framework serializes app state into HTML and resumes execution in the browser without a hydration replay. Signal reads and writes work the same as in Solid but components can be split across the network boundary. There is no “stale closure” because there are no captured render-time values to go stale.

Batching strategies. React 18 batches all updates inside synchronous handlers including setTimeout, fetch, and native event listeners. Vue batches in a microtask queue and flushes before paint. Solid and Svelte 5 batch through their fine-grained reactivity scheduler. The “two setState calls produce one render” behavior is universal but the escape hatches differ: React has flushSync, Vue has await nextTick(), Solid has flushSync too. Mix these up and you will fight ordering bugs that look like state not updating.

Immutability requirement. React, Redux, and Zustand require new references for state changes. Vue’s reactive, MobX, and Solid stores all detect mutation through proxies and accept in-place changes. Porting Vue habits to React produces exactly the bug in Fix 3: mutate, set, no re-render.

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 effects have incorrect dependency arrays or when handlers are registered in effects that never re-run.

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.

Concurrent Features and useTransition

If you wrap a state update in useTransition, React marks it as low priority and may interrupt or defer it. During the transition, isPending is true and the previous state value continues to render. This is by design — the new state has not “lost” — but it looks like the update is delayed:

const [isPending, startTransition] = useTransition();

function handleSearch(value) {
  startTransition(() => {
    setQuery(value);
  });
}

If a higher-priority interruption (a typed character, a click) arrives before the transition commits, React may discard the in-progress render and start over. Combined with strict mode double rendering, this can make state updates look like they happened twice or not at all in console logs. Move logs into useEffect triggered by the dependency rather than directly inside the handler to see the committed value.

Strict Mode Double Invocation of Setters

React Strict Mode in development double-invokes setter functions to expose impure updaters. If your functional setter has a side effect, it runs twice and your state ends up incremented by two instead of one:

setCount(prev => {
  console.log("setter ran");  // logs twice in StrictMode
  globalCounter += 1;          // BUG: increments twice
  return prev + 1;             // safe: pure
});

Keep setters pure — return a new value based on prev and nothing else. Move any side effect into useEffect. If you ever see “the counter jumped by two in dev but works fine in prod,” this is the cause.

React Server Components and Server-Only State

React Server Components do not have useState at all. Importing useState into a file without "use client" at the top throws at build time. If you tried to add state and saw a silent no-op or a bundler error, the file is being treated as a server component. Add "use client" to the top of the file (or extract the stateful piece into a client component) to enable hooks.

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