React useState's Three Hidden Behaviors That Trip Up Beginners
Foreword
If you're new to React, the first Hook you'll likely encounter is useState. This article uses two small examples in a Vite + React project to break down its working mechanism step by step. We don't aim for exhaustive coverage, just to clarify the most core concepts.
The code files involved in this article:
- src/App.jsx — User list + filtering, demonstrating lazy initialization and derived state
- src/App2.jsx — Counter, demonstrating asynchronous updates and functional updates
1. What useState Can Do
Simply put, useState lets you have "mutable data" inside a function component. A counter might look like this:
const [count, setCount] = useState(0)
count is the current value, and setCount is the function used to change the value. After changing the value, React will re-run the component function, and the page will update accordingly.
Traditional variables can't do this—you change the value, but the page doesn't automatically refresh. useState builds the bridge of "data change → UI re-render."
2. State Changes Are Actually "Queued"
Look at this code from App2.jsx:
const [count, setCount] = useState(0)
const addCount = () => {
setCount(count + 1)
console.log(count) // Does this print the old value or the new value?
}
Many beginners intuitively think console.log(count) should print 1. But in reality, it prints 0.
The reason is that setCount doesn't immediately modify count. What it does is more like: "Hey React, I have an update here, handle it when you get a chance." Then the current code continues to run. After the entire block finishes, React uniformly processes the queued updates and triggers a single re-render.
The execution order can be understood like this:
User clicks button
↓
setCount(count + 1) → Adds update to queue, count remains unchanged for now
↓
console.log(count) → Synchronous code, gets the pre-update value of 0
↓
Current event handler finishes execution
↓
React processes updates in the queue, component function re-executes
↓
count becomes 1, page updates
This mechanism is usually called batching—React merges multiple setState calls within a short period to avoid re-rendering for every single value change, which would waste performance.
3. The Small Trap of Multiple Consecutive setStates
Suppose you want to increment the counter by 3 with one button click:
setCount(count + 1) // count is currently 0
setCount(count + 1) // count is currently 0 (not yet updated)
setCount(count + 1) // count is currently 0 (not yet updated)
All three calls get count as 0. After React's batch processing, the final count becomes 1, not 3.
This isn't a bug, but a deliberate optimization strategy by React. React tries to merge multiple setStates within the same event handler.
So how do you achieve the +3 effect? This is where functional updates come in:
setCount(prev => prev + 1) // prev = 0 → returns 1
setCount(prev => prev + 1) // prev = 1 → returns 2
setCount(prev => prev + 1) // prev = 2 → returns 3
When you pass a function, not a value, to setCount, React puts this function into a queue and executes them in order. Each step's prev is the return value of the previous step, so you ultimately get the correctly accumulated result.
When to use functional updates? A simple rule of thumb: if your new state depends on the old state, using a functional update is generally safer. It doesn't rely on the old snapshot in the closure but always gets the latest result from the previous step.
4. What to Do When the Initial Value Is "Expensive"
App.jsx has a function simulating an "expensive computation":
function heavyComputation() {
console.log('Starting heavyComputation....')
const startTime = performance.now()
const result = []
for (let i = 0; i < 10000; i++) {
result.push({ id: i, name: `User:${i}` })
}
console.log(`heavyComputation execution time: ${performance.now() - startTime}ms`)
return result
}
Suppose the initial state needs to run a relatively time-consuming logic like this, such as generating a large amount of user data. Two ways of writing it look similar, but the effects are completely different:
// Method A
const [users] = useState(heavyComputation())
// Method B
const [users] = useState(() => heavyComputation())
Method A: heavyComputation() executes on every render. Although React ignores the return value on subsequent renders (only taking the first one), the function itself ran, and time was spent.
Method B: What's passed in is a function reference () => heavyComputation(). React only calls it when the component first mounts. On subsequent re-renders, React sees a function was passed in and skips it directly.
This is lazy initialization. When the initial value requires complex computation, passing a function instead of the computed result avoids unnecessary overhead.
Of course, if the initial value is just a simple 0 or [], there's no need to overthink it—passing the value directly is perfectly fine.
5. Not All Data Needs to Become State
There's another noteworthy detail in App.jsx:
const [users] = useState(() => heavyComputation())
const [filterText, setFilterText] = useState('')
// Note: filteredUsers is not state
const filteredUsers = users.filter(user =>
user.name.includes(filterText)
)
users and filterText are state, while filteredUsers is just a regular variable—it's computed from users and filterText.
This is a very useful mindset: values that can be derived don't need to be stored as a separate piece of state. Every time the component re-renders, filteredUsers naturally recalculates based on the latest users and filterText.
Conversely, if you also set it as state:
const [filteredUsers, setFilteredUsers] = useState(users)
You'd need to manually synchronize and update it whenever users or filterText changes, adding unnecessary maintenance cost and potential for errors.
This type of value is often called computed / derived state in the React community. It's not a formal API, but a design mindset: state should be the "minimum necessary few," and the rest should be left to computation.
6. Understanding State from Another Angle
Putting API details aside, you can understand a React component like this:
Component = a function
Rendering = running this function
State = variables inside the function that can change, and whose change causes the function to re-run
Every render is an independent function call, and the variables inside the function form a "snapshot." What setState essentially does is tell React: "After this round finishes, run it again with the new value."
With this perspective:
- Why does
console.log(count)get the old value? Because the current function call hasn't ended yet, and the snapshot is still the old one. - Why can functional updates break through the limitation? Because
prev => prev + 1doesn't depend on the snapshot in the closure; it reads the latest value managed internally by React. - Why is lazy initialization useful? Because the function re-executes every time, but we want certain expensive logic to run only the "first" time.
7. Summary
| Key Point | In One Sentence |
|---|---|
| The role of useState | Lets function components have data that triggers re-renders |
| Asynchronicity of setState | Updates are queued; the new value cannot be read within the current code block |
| Batch merging | Multiple setStates within the same event are merged for processing |
| Functional updates | prev => newValue is used to safely depend on the old value |
| Lazy initialization | When initial computation is expensive, passing a function is more efficient than passing a value |
| Derived state | Values that can be derived should not be set as state, reducing redundancy |
This article focuses on the most core behaviors of useState. If you want to understand how it cooperates with other components in real projects (state lifting, parent-child communication, etc.), you can refer to the code and notes of the todolist project in the same directory.