useState Hook
Manage local component state with React's most fundamental hook.
Recipe
Quick-reference recipe card — copy-paste ready.
const [value, setValue] = useState<T>(initialValue)
// With lazy initializer (expensive computation)
const [value, setValue] = useState(() => computeExpensive())
// Updater function (when new state depends on previous)
setValue(prev => prev + 1)When to reach for this: You need local, synchronous state in a single component.
Working Example
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount(prev => prev - 1)}
className="px-3 py-1 border rounded"
>
-
</button>
<span className="text-xl font-mono w-12 text-center">{count}</span>
<button
onClick={() => setCount(prev => prev + 1)}
className="px-3 py-1 border rounded"
>
+
</button>
</div>
);
}What this demonstrates:
- Basic
useStatewith a number - Using the updater function
prev => prev + 1instead ofsetCount(count + 1)to avoid stale closure issues - The component re-renders only when
countchanges
Deep Dive
How It Works
useStatereturns a tuple: the current state value and a setter function- On the initial render, the state is set to
initialValue - On subsequent renders, React returns the latest state
- When you call the setter, React schedules a re-render with the new value
- React batches multiple
setStatecalls within the same event handler into a single re-render for performance
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
initialValue | T or () => T | Initial state value, or a function that returns it (lazy initializer) |
| Return | Type | Description |
|---|---|---|
value | T | Current state value |
setValue | (value: T) => void or (prev: T) => T | State updater — accepts a new value or an updater function |
Variations
Object state:
const [form, setForm] = useState({ name: "", email: "" });
// Must spread to create new reference
setForm(prev => ({ ...prev, name: "Alice" }));Array state:
const [items, setItems] = useState<string[]>([]);
setItems(prev => [...prev, "new item"]);Lazy initializer (runs only on mount):
const [data, setData] = useState(() => {
return JSON.parse(localStorage.getItem("key") ?? "null");
});TypeScript Notes
// Type is inferred from initial value
const [count, setCount] = useState(0); // number
// Explicit generic for union types or null
const [user, setUser] = useState<User | null>(null);
// Explicit generic for complex types
const [items, setItems] = useState<Item[]>([]);Gotchas
Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.
-
Stale closure trap — Reading
countinside asetTimeoutoruseEffectwithout it in the dependency array gives you the old value. Fix: Use the updater functionsetCount(prev => prev + 1). -
Object identity —
setState({ ...obj })creates a new reference every time, even if values haven't changed, causing unnecessary re-renders. Fix: Only spread when values actually change, or useuseMemofor derived values. -
Lazy initializer pitfall — Passing
computeExpensive()instead of() => computeExpensive()runs the function on every render, not just the first. Fix: Always wrap expensive computations in an arrow function. -
Batching gotcha — Calling
setCount(count + 1)three times in a row results in only +1, not +3, because each call reads the samecount. Fix: Use the updater functionsetCount(prev => prev + 1).
Alternatives
Other ways to solve the same problem — and when each is the better choice.
| Alternative | Use When | Don't Use When |
|---|---|---|
useReducer | State transitions are complex or depend on previous state | Simple toggle or single value |
| Zustand store | State is shared across many unrelated components | State is local to one component |
| URL search params | State should survive page refresh and be shareable | High-frequency updates (typing, dragging) |
useRef | You need a mutable value that doesn't trigger re-renders | You need the UI to reflect the value |
Why not just always use Zustand? Zustand adds a dependency and indirection. useState is zero-cost for local state — no provider, no store, no selectors. Use the simplest tool that works.
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: FAQ edit form with multiple useState
// File: src/components/admin/faq-edit-form.tsx
'use client';
import { useState } from 'react';
interface FaqEditFormProps {
faq: Faq & { category?: { id: string; slug: string; title: string } };
onSave: (updatedFaq: Partial<Faq>) => Promise<void>;
onCancel: () => void;
}
export default function FaqEditForm({ faq, onSave, onCancel }: FaqEditFormProps) {
const [question, setQuestion] = useState(faq.question);
const [answer, setAnswer] = useState(faq.answer);
const [isActive, setIsActive] = useState(faq.isActive);
const [sortOrder, setSortOrder] = useState(faq.sortOrder);
const [isSaving, setIsSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await onSave({ question, answer, isActive, sortOrder });
} catch (error) {
console.error('Error saving FAQ:', error);
} finally {
setIsSaving(false);
}
};
// ... form JSX
}What this demonstrates in production:
- TypeScript infers the type of each state variable from the initial value passed to
useState. No explicit generic needed foruseState(faq.question)sincefaq.questionis already typed asstring. - The
finallyblock ensuresisSavingis reset tofalseon both success and failure. Without it, a failed save would leave the form stuck in a loading state. Partial<Faq>means only the changed fields are sent to the save handler, not the entire FAQ object. This keeps the API call lean.- Five separate
useStatecalls work fine for a form this size. For forms with more than 6-8 fields, consideruseReduceror a form library likereact-hook-formto reduce boilerplate and enable field-level validation. - The
isSavingflag is used to disable the submit button during the async operation, preventing double-submission.
FAQs
Why should I use the updater function form setCount(prev => prev + 1) instead of setCount(count + 1)?
- When you call
setCount(count + 1)multiple times in the same event handler, each call reads the same stalecountvalue. - The updater form
prev => prev + 1always receives the latest pending state, so three calls result in +3 instead of +1. - It also avoids stale closure bugs inside
setTimeout,useEffect, and async functions.
What is a lazy initializer and when should I use one?
- A lazy initializer is a function passed to
useState:useState(() => expensiveComputation()). - It runs only on the first render (mount), not on every re-render.
- Use it when your initial value requires an expensive computation, such as reading from
localStorageor parsing large data.
How does React batch multiple setState calls?
- React batches all
setStatecalls within the same event handler into a single re-render. - In React 18+, batching also applies to
setTimeout, promises, and native event handlers (automatic batching). - This means calling
setA(1); setB(2);results in one re-render, not two.
Can I store an object in useState and update just one property?
const [form, setForm] = useState({ name: "", email: "" });
// Correct: spread to create a new reference
setForm(prev => ({ ...prev, name: "Alice" }));
// Wrong: mutating the existing object
form.name = "Alice"; // No re-renderWhy doesn't my component re-render when I update an object with the same values?
- React uses
Object.isto compare the old and new state. - If you spread the object (
{ ...obj }), you create a new reference, which triggers a re-render even if values are identical. - If you pass the exact same object reference, React skips the re-render.
Gotcha: What happens if I pass a function directly instead of wrapping it in an arrow function for the lazy initializer?
useState(computeExpensive())calls the function on every render and uses the result only on the first.useState(() => computeExpensive())calls the function only on the first render.- The first form wastes computation on every re-render.
How do I type useState with TypeScript when the initial value is null?
// Use an explicit generic for union types
const [user, setUser] = useState<User | null>(null);
// Later, TypeScript knows user can be null
if (user) {
console.log(user.name); // narrowed to User
}How do I type an array state in TypeScript when starting with an empty array?
// Without the generic, TypeScript infers never[]
const [items, setItems] = useState<string[]>([]);
// Now you can push strings
setItems(prev => [...prev, "new item"]);When should I use useState vs useReducer?
- Use
useStatefor simple, independent values (a toggle, a counter, a single input). - Use
useReducerwhen you have 3+ related state values, or when the next state depends on both the current state and an action payload. - For forms with more than 6-8 fields,
useReduceror a form library reduces boilerplate.
Gotcha: Why does calling setState inside a render function cause an infinite loop?
- Calling
setStateduring render schedules a new render, which callssetStateagain, creating an infinite loop. - React will throw a "Too many re-renders" error.
- State updates should happen in event handlers, effects, or callbacks -- never unconditionally during render.
Is it better to have one useState with an object or multiple separate useState calls?
- Multiple
useStatecalls are simpler and avoid unnecessary spreads when only one value changes. - A single object state is better when values always change together (e.g.,
{ x, y }coordinates). - For forms with more than 6 fields, consider
useReducerinstead of either approach.
Related
- useReducer — for complex state logic with actions
- useRef — for mutable values that don't trigger re-renders
- Zustand Setup — when state needs to be shared globally