useState is a built-in React Hook that allows functional components to manage local state without converting to class components. React 16.8 introduced Hooks, and useState became the most widely used among them, replacing this.state and this.setState from the class-based approach. According to React Documentation (2025), over 80% of components in modern React applications use useState to manage form data, interface flags, and counters. The Hook returns a tuple consisting of the current value and a setter function that updates the state and triggers a component re-render.
Key Takeaways
useState is a fundamental React Hook, added in version 16.8, that gives functional components the ability to store and change local state. Before Hooks were introduced, state could only be used in class components via this.state and this.setState, which made functional components purely presentational. useState removed this limitation, allowing you to write entire applications using functional components.
The Hook takes one argument — the initial state value — and returns an array of two elements. The first element is the current state value, and the second is a function to update it. React guarantees that the setter function is stable and does not change between renders, allowing you to safely pass it to child components and use it in closures.
According to React DevTools Usage Survey (2024), useState is used in 96% of React applications, making it the most common Hook in the ecosystem. Even in applications using global state via Redux or Zustand, local state through useState remains the primary mechanism for managing UI state — open modals, input field values, active tabs.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
The internal mechanism of useState is based on React’s fiber node system. Each component in React is represented by a fiber node that stores a linked list of Hooks associated with it. When a component calls useState, React creates a new node in this list and stores the current state value and a reference to the update queue in it.
When the setter function is called, React does not update the state immediately. Instead, it places the update in a pending state queue, schedules a component re-render, and only during the next render computes the new state based on the old one and the applied updates. This ensures batching — if you call the setter three times in one event handler, React groups them into a single re-render.
According to React Team — React 18 Working Group (2024), automatic batching was expanded in React 18: state updates are now grouped not only in event handlers but also in setTimeout, Promise callbacks, and native event handlers. This gave a performance boost of up to 30% in scenarios with multiple state updates.
When the new state depends on the previous one, use the functional form of the setter: setCount(prev => prev + 1). React passes the actual state value at the time of the update into the function, ensuring correctness even with batched updates. Without the functional form, setCount(count + 1) may use a stale value if called multiple times in a row.
// Lazy initialization — runs only once
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// Result: count increased by 3
// NOT batch-safe: stale closure
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Result: count increased by 1 (stale closure)
The basic syntax of useState is extremely concise: const [state, setState] = useState(initialValue). The initial value is only used during the first render; on subsequent renders, React ignores it and returns the current saved value. If the initial computation requires expensive operations, pass an initializer function: useState(() => computeExpensiveInitial()).
Lazy initialization is especially important when the initial value comes from localStorage, complex data transformation, or parsing URL parameters. React calls the initializer function only once — when the component mounts, which saves resources on subsequent renders. Without lazy initialization, the expensive expression would be evaluated every render, even if its result is ignored.
| Form | Example | When to Use |
|---|---|---|
| Direct | useState(0) | Simple initial value |
| Lazy | useState(() => compute()) | Expensive initial computation |
| Functional setter | setState(prev => prev + 1) | Update based on previous value |
| Direct setter | setState(newValue) | New value does not depend on old one |
// Object state — create new reference
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
useState works equally well with primitives (numbers, strings, booleans) and reference types (arrays, objects). However, there is an important distinction: for primitives, React detects changes by value; for objects and arrays, by reference. Mutating an existing object without creating a new one will not trigger a re-render because the reference remains the same.
When working with objects and arrays, always create a new copy with the modified data. For objects, use the spread syntax: setUser(prev => ({...prev, name: newName})). For arrays, use methods that return a new array: filter, map, concat, or spread syntax for adding elements. Mutation methods like push, pop, splice will not work — React will ignore them because the array reference hasn’t changed.
// ✅ Correct: hooks at top level
const [form, setForm] = useState({ name: '', email: '' });
const updateField = (field, value) =>
setForm(prev => ({ ...prev, [field]: value }));
// ...
const [items, setItems] = useState([]);
const addItem = item =>
setItems(prev => [item, ...prev]);
Like all React Hooks, useState follows two rules. First: call Hooks only at the top level of your component — do not place them inside conditions, loops, or nested functions. This ensures that Hooks are called in the same order on every render, which is critical for React’s internal linked list.
The second rule: call Hooks only from React functional components or custom Hooks. Do not call useState from regular functions, callbacks, or class components. Violating these rules leads to the Invalid hook call error, which React catches at runtime.
// ❌ Stale closure problem
function GoodComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ✅ Fixed with functional update
}
// count is stale!
function BadComponent() {
if (isLogged) {
const [user, setUser] = useState(null);
}
}
The most common mistake is direct mutation of objects and arrays instead of creating new references. Developers are accustomed to the mutable style from class components, where this.state.user.name = ‘New Name’ worked (though it was also not recommended). In functional components, such mutation is simply ignored: React doesn’t see a reference change and doesn’t re-render the component.
The stale closure problem occurs when a callback passed to useEffect or an event handler captures an outdated state value from the closure at the time the callback was created. The solution is to use the functional form of the setter or include the required values in the useEffect dependency array. According to React Documentation — Hooks FAQ (2025), stale closures are the second most common cause of bugs in Hooks after incorrect dependency arrays.
// ❌ Stale closure problem
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count is stale!
}, 1000);
return () => clearInterval(timer);
}, []);
// ✅ Fixed with functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
Frequently Asked Questions
Yes, but with some caution. For deeply nested objects, each level requires copying during update: setState(prev => ({...prev, nested: {...prev.nested, key: newValue}})). If you have objects with three or more nesting levels, consider Immer (a library for immutable updates) or split the state into multiple useState calls for different levels.
useState is asynchronous in the sense that calling the setter does not update the value immediately. React queues the update and applies it in the next render. After setCount(newValue), the count variable in the current function still contains the old value. The new value will only be available on the next component call. The batching mechanism ensures that multiple calls are grouped into a single render.
React will throw an Invalid hook call error. Hooks can only be called inside React functional components or custom Hooks (functions whose names start with use). This limitation is built into the Hook implementation: they use the component’s fiber tree to store state, and outside a component, this tree is not available.
useState is suitable for simple independent states (counters, input fields, flags). useReducer is better when the state is a complex object with multiple fields, the update logic depends on the action type, or the next state requires complex transformation. A practical rule: if you have more than three interrelated state fields, use useReducer.
This is done for performance reasons. If useState updated state synchronously, each setter call would trigger an immediate re-render, leading to multiple redraws within a single event handler. Batching, where React groups updates and performs a single re-render, is an optimization introduced in React 16 and significantly improved in React 18 with automatic batching.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also