useState Hook
Manage local component state with React's most fundamental hook.
Search across all documentation pages
Manage local component state with React's most fundamental hook.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
useState with a numberprev => prev + 1 instead of setCount(count + 1) to avoid stale closure issuescount changesuseState returns a tuple: the current state value and a setter functioninitialValuesetState calls within the same event handler into a single re-render for performance| 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 |
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");
});// 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[]>([]);Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.
Stale closure trap - Reading count inside a setTimeout or useEffect without it in the dependency array gives you the old value. Fix: Use the updater function setCount(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 use useMemo for 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 same count. Fix: Use the updater function setCount(prev => prev + 1).
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.
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:
useState. No explicit generic needed for useState(faq.question) since faq.question is already typed as string.finally block ensures isSaving is reset to false on 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.useState calls work fine for a form this size. For forms with more than 6-8 fields, consider useReducer or a form library like react-hook-form to reduce boilerplate and enable field-level validation.isSaving flag is used to disable the submit button during the async operation, preventing double-submission.setCount(count + 1) multiple times in the same event handler, each call reads the same stale count value.prev => prev + 1 always receives the latest pending state, so three calls result in +3 instead of +1.setTimeout, useEffect, and async functions.useState: useState(() => expensiveComputation()).localStorage or parsing large data.setState calls within the same event handler into a single re-render.setTimeout, promises, and native event handlers (automatic batching).setA(1); setB(2); results in one re-render, not two.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-renderObject.is to compare the old and new state.{ ...obj }), you create a new reference, which triggers a re-render even if values are identical.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.// 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
}// Without the generic, TypeScript infers never[]
const [items, setItems] = useState<string[]>([]);
// Now you can push strings
setItems(prev => [...prev, "new item"]);useState for simple, independent values (a toggle, a counter, a single input).useReducer when you have 3+ related state values, or when the next state depends on both the current state and an action payload.useReducer or a form library reduces boilerplate.setState during render schedules a new render, which calls setState again, creating an infinite loop.useState calls are simpler and avoid unnecessary spreads when only one value changes.{ x, y } coordinates).useReducer instead of either approach.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥