Understanding React useState: Why State Updates Don’t Reflect Immediately
If you’ve worked with React’s useState hook, you've likely encountered this head-scratching moment: you update state, immediately log it, and... the old value still appears. This isn't a bug—it's a fundamental aspect of how React works. Let's dive deep into why this happens and how to handle it properly.
The Problem: State Updates Aren’t Instantaneous
Picture this scenario: you’re building a simple text updater with a button that changes color based on the text state. You write something like this:
const [text, setText] = useState("default text");
const [buttonColor, setButtonColor] = useState("pink");const updateText = () => {
setText("updated text");
console.log(text); // Still shows "default text" 😕
if (text === "updated text") {
setButtonColor("green");
} else {
setButtonColor("pink");
}
};return (
<>
<h2>{text}</h2>
<button
onClick={updateText}
style={{ padding: '1rem 3rem', backgroundColor: buttonColor }}
>
Update
</button>
</>
);
The button stays pink even though the text updates! What’s happening here?
Why This Happens: React’s Asynchronous Nature
Understanding the Core Mechanism
React’s state updates are synchronous to schedule but asynchronous to apply. When you call setText(), React doesn't immediately mutate the state and re-render. Instead, it follows a sophisticated process:
1. Automatic Batching (Enhanced in React 18+)
React 18 introduced automatic batching for all updates, not just those in event handlers. This means multiple state updates are grouped together and processed in a single re-render cycle, dramatically improving performance. Whether you’re updating state in promises, timeouts, or native event handlers, React batches them automatically.
// All three updates are batched into one re-render
const handleClick = async () => {
setText("new text");
setButtonColor("green");
setCount(count + 1);
// Component re-renders only once with all updates
};2. Event Loop Integration
React schedules state updates within JavaScript’s event loop. This keeps your UI responsive by allowing the browser to handle other critical tasks between updates.
3. Render and Commit Phases
React’s rendering process involves two critical phases:
- Render Phase: React calculates what needs to change in the Virtual DOM
- Commit Phase: React applies those changes to the actual DOM
- Post-Commit Phase: Effects (like
useEffect) execute
Your console.log(text) runs in the same function call before any of these phases complete, which is why it shows the old value.
The Closure Trap
Even if you wrap your state access in a setTimeout, you'll still see the stale value:
const updateText = () => {
setText("updated text");
setTimeout(() => {
console.log(text); // Still shows "default text"!
}, 1000);
};This happens because the timeout captures the text value from when it was created (a closure), not the updated value.
Modern Solutions (2024–2025)
Solution 1: useEffect Hook (The Classic Approach)
The most straightforward solution is to use useEffect with your state variable in the dependency array:
const [text, setText] = useState("default text");
const [buttonColor, setButtonColor] = useState("pink");useEffect(() => {
console.log(text); // Now shows "updated text" ✅
if (text === "updated text") {
setButtonColor("green");
} else {
setButtonColor("pink");
}
}, [text]);const updateText = () => {
setText("updated text");
};
This works because useEffect runs after the commit phase, when the DOM has been updated with the new state.
How it works step by step:
- State changes and component re-renders
- During the render phase, React processes your JSX and updates the virtual DOM
- In the commit phase, React updates the actual DOM
- In the post-commit phase, React executes
useEffectfunctions - If dependencies changed, the effect runs with the updated state value
Solution 2: Functional Updates (Often Overlooked)
When your new state depends on the previous state, use the functional update form:
const updateText = () => {
setText(prevText => {
const newText = "updated text";
// Access the most current state value here
if (newText === "updated text") {
setButtonColor("green");
} else {
setButtonColor("pink");
}
return newText;
});
};This ensures you’re always working with the latest state value, even with batching.
Solution 3: useRef for Non-Rendering Values
If you need to track a value but don’t need React to re-render when it changes, useRef is perfect:
import { useRef, useState } from 'react';const Component = () => {
const textRef = useRef('default text');
const [buttonColor, setButtonColor] = useState("pink");
const updateText = () => {
textRef.current = "updated text";
console.log(textRef.current); // Shows "updated text" immediately ✅
if (textRef.current === "updated text") {
setButtonColor("green");
} else {
setButtonColor("pink");
}
};
return (
<div>
<h1>{textRef.current}</h1>
<button
onClick={updateText}
style={{ padding: '1rem 3rem', backgroundColor: buttonColor }}
>
Update
</button>
</div>
);
};Important note: Updating a ref doesn’t trigger re-renders. The useRef hook returns the same object reference on every render, and modifying ref.current doesn't notify React of any changes. You'll need to combine it with state updates if you want the UI to reflect changes automatically.
Solution 4: useTransition (React 18+)
For non-urgent updates that can be interrupted, React 18’s useTransition helps maintain a responsive UI:
import { useState, useTransition } from 'react';const Component = () => {
const [text, setText] = useState("default text");
const [isPending, startTransition] = useTransition();
const updateText = () => {
startTransition(() => {
setText("updated text");
});
};
return (
<div>
<h2>{text}</h2>
<button onClick={updateText}>
{isPending ? 'Updating...' : 'Update'}
</button>
</div>
);
};This marks state updates as non-urgent, allowing React to keep the UI responsive during heavy updates.
Solution 5: State Management Libraries (For Complex Apps)
For larger applications with complex state logic, consider modern state management solutions. These libraries centralize state management and provide more predictable update patterns.
Get Muhammad Zubair Asim’s stories in your inbox
Join Medium for free to get updates from this writer.
Zustand (Lightweight and simple):
import { create } from 'zustand';const useStore = create((set) => ({
text: "default text",
buttonColor: "pink",
updateText: () => set((state) => {
const newText = "updated text";
return {
text: newText,
buttonColor: newText === "updated text" ? "green" : "pink"
};
})
}));// In your component
const { text, buttonColor, updateText } = useStore();
Redux Toolkit (Comprehensive solution):
import { createSlice } from '@reduxjs/toolkit';const textSlice = createSlice({
name: 'text',
initialState: { value: "default text", color: "pink" },
reducers: {
updateText: (state) => {
state.value = "updated text";
state.color = state.value === "updated text" ? "green" : "pink";
}
}
});Other popular options include Jotai for atomic state management and TanStack Query for server state.
What About React 19?
React 19 (currently in development as of 2025) continues to enhance the concurrent rendering model. The core principle remains the same: state updates are batched and asynchronous. However, React 19 introduces the new use hook and improved server components, making state management even more powerful while maintaining the same fundamental behavior.
Best Practices for State Management
- Embrace the async nature: Don’t fight React’s design — understand and work with it
- Use functional updates: Always use the callback form when new state depends on previous state
- Leverage useEffect: Perfect for side effects that need to run after state updates
- Choose useRef wisely: Only when you don’t need re-renders and want immediate access
- Consider derived state: Sometimes you don’t need separate state at all — calculate values during render
- Avoid excessive state: Not everything needs to be in state; local variables work for transient values
Common Pitfall: Derived State
Often, you don’t need separate state variables that depend on each other. Consider this refactored example:
const [text, setText] = useState("default text");// Derive the button color instead of storing it in state
const buttonColor = text === "updated text" ? "green" : "pink";const updateText = () => {
setText("updated text");
};return (
<>
<h2>{text}</h2>
<button
onClick={updateText}
style={{ padding: '1rem 3rem', backgroundColor: buttonColor }}
>
Update
</button>
</>
);
This eliminates the synchronization issue entirely by calculating buttonColor on every render based on the current text value.
When to Use Each Solution
- useEffect: When you need side effects after state updates (API calls, DOM manipulation, subscriptions)
- Functional updates: When new state depends on previous state
- useRef: When you need a mutable value that doesn’t trigger re-renders
- Derived state: When one value can be calculated from another
- State management libraries: When you have complex, shared state across many components
- useTransition: When you have expensive updates that can be deprioritized
Conclusion
React’s state update batching is a performance optimization, not a limitation. Once you understand that state updates are scheduled asynchronously, you can choose the right pattern for your use case. In most scenarios, useEffect handles the need to react to state changes, but modern React offers multiple powerful approaches depending on your requirements.
The key takeaway is simple: state updates are asynchronous by design. Instead of trying to access state immediately after updating it, embrace React’s declarative nature and let the framework handle when and how to apply updates for optimal performance.
Remember, whether you’re working with class components using lifecycle methods or functional components with hooks, the fundamental behavior remains the same. React batches updates, processes them asynchronously, and re-renders your components efficiently. Understanding this core concept will make you a more effective React developer.
Happy coding! 🚀
Have questions or insights about React state management? Drop them in the comments below!








