React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

statehooksfundamentals

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 useState with a number
  • Using the updater function prev => prev + 1 instead of setCount(count + 1) to avoid stale closure issues
  • The component re-renders only when count changes

Deep Dive

How It Works

  • useState returns 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 setState calls within the same event handler into a single re-render for performance

Parameters & Return Values

ParameterTypeDescription
initialValueT or () => TInitial state value, or a function that returns it (lazy initializer)
ReturnTypeDescription
valueTCurrent state value
setValue(value: T) => void or (prev: T) => TState 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 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 identitysetState({ ...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).

Alternatives

Other ways to solve the same problem — and when each is the better choice.

AlternativeUse WhenDon't Use When
useReducerState transitions are complex or depend on previous stateSimple toggle or single value
Zustand storeState is shared across many unrelated componentsState is local to one component
URL search paramsState should survive page refresh and be shareableHigh-frequency updates (typing, dragging)
useRefYou need a mutable value that doesn't trigger re-rendersYou 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 for useState(faq.question) since faq.question is already typed as string.
  • The 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.
  • Five separate 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.
  • The isSaving flag 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 stale count value.
  • The updater form prev => prev + 1 always 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 localStorage or parsing large data.
How does React batch multiple setState calls?
  • React batches all setState calls 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-render
Why doesn't my component re-render when I update an object with the same values?
  • React uses Object.is to 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 useState for simple, independent values (a toggle, a counter, a single input).
  • Use useReducer when 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, useReducer or a form library reduces boilerplate.
Gotcha: Why does calling setState inside a render function cause an infinite loop?
  • Calling setState during render schedules a new render, which calls setState again, 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 useState calls 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 useReducer instead of either approach.
  • 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