useState: Simple on the Surface, Deep Underneath

By Odilon10 min read

useState: Simple on the Surface, Deep Underneath#

TL;DR

  • Use the updater function form (prev => prev + 1) whenever the new state depends on the old state
  • Never mutate state objects — always create new references
  • Lazy initialization avoids expensive computations on every render

Let me start with a bug that cost an entire Saturday.

The task was building a bulk-select feature for an order management table. Users could check multiple orders and then apply a batch action — mark as shipped, print labels, etc. The checkbox state was stored in a useState with a Set. Clicking "Select All" was supposed to add all visible order IDs to the set.

It did not work. Sometimes it selected all orders. Sometimes it selected one. Sometimes it selected a random subset. Console.logs everywhere. Blame React. Blame JavaScript.

The bug was state update batching. The setSelectedIds calls inside a loop were each reading the stale value from the closure instead of the latest queued value. The fix was one line — switching from direct state to the updater function form.

That bug taught me that useState is simple on the surface and genuinely subtle underneath. This post covers the parts that bite you.

The Basics#

useState returns a pair: the current state value, and a function to update it. When you call the update function, React schedules a re-render with the new value.

tsx
const [count, setCount] = useState(0);

The initial value is only used on the first render. Subsequent renders return the current state. The 0 in that call is not a "default" that resets — it is the value for render one only.

Primitives vs. Objects#

For primitive values, useState is straightforward:

tsx
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'details' | 'reviews'>('overview');
const [searchQuery, setSearchQuery] = useState('');

For objects, you need to be more deliberate. React does not merge state like setState did in class components. If your state is an object, you must spread the old values yourself when updating one field:

tsx
interface FilterState {
  searchQuery: string;
  category: string;
  minPrice: number;
  maxPrice: number;
  inStockOnly: boolean;
}

function ProductFilters() {
  const [filters, setFilters] = useState<FilterState>({
    searchQuery: '',
    category: 'all',
    minPrice: 0,
    maxPrice: 1000,
    inStockOnly: false,
  });

  function updateCategory(category: string) {
    setFilters(prev => ({ ...prev, category }));
    //                    ^^^^^^^^^^ without this spread you lose everything else
  }

  function resetFilters() {
    setFilters({
      searchQuery: '',
      category: 'all',
      minPrice: 0,
      maxPrice: 1000,
      inStockOnly: false,
    });
  }

  return (
    <div>
      <input
        value={filters.searchQuery}
        onChange={e => setFilters(prev => ({ ...prev, searchQuery: e.target.value }))}
        placeholder="Search products"
      />
      {/* ...other filter controls */}
    </div>
  );
}

Without the spread: { ...prev, category }, you would replace the entire filters object with just { category: 'electronics' } and lose searchQuery, minPrice, and everything else. I have seen this bug in code reviews more times than I can count.

The Updater Function: This Is the Saturday Bug#

This is the mistake that cost me my weekend:

tsx
// Wrong: reads current state from closure, not from React's queue
function handleMultipleUpdates() {
  setCount(count + 1); // count is still 0 here
  setCount(count + 1); // count is still 0 here — does nothing new
  setCount(count + 1); // still 0 — you end up with count === 1, not 3
}

// Right: updater function receives the latest queued value
function handleMultipleUpdates() {
  setCount(prev => prev + 1); // prev is 0, next is 1
  setCount(prev => prev + 1); // prev is 1, next is 2
  setCount(prev => prev + 1); // prev is 2, next is 3
}

The prev => form tells React: "give me the most up-to-date queued value, compute the next value from it." The direct form reads the value from the current render's closure, which is stale if multiple updates are batched.

My recommendation: use the updater function form any time the new state depends on the previous state. It costs nothing and prevents an entire class of bugs.

tsx
// The actual fix for my Saturday bug
function useSelectionSet<T extends string>(initialItems: T[] = []) {
  const [selectedIds, setSelectedIds] = useState<Set<T>>(
    () => new Set(initialItems)
  );

  function toggle(id: T) {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }

  function selectAll(ids: T[]) {
    // This was the broken line. Before: setSelectedIds(new Set([...selectedIds, ...ids]))
    // selectedIds was stale. The fix:
    setSelectedIds(prev => new Set([...prev, ...ids]));
  }

  function clearAll() {
    setSelectedIds(new Set());
  }

  return { selectedIds, toggle, selectAll, clearAll };
}

Lazy Initialization#

The initial value argument to useState is evaluated on every render call — but only the result of the first render is actually used. This means expensive computations in the initial value are wasted on every subsequent render.

tsx
// Wrong: localStorage.getItem() is called on every render
const [preferences, setPreferences] = useState(
  JSON.parse(localStorage.getItem('preferences') ?? '{}')
);

// Right: lazy initializer is called only once
const [preferences, setPreferences] = useState(
  () => JSON.parse(localStorage.getItem('preferences') ?? '{}')
);

Pass a function (without calling it) and React will call that function once to compute the initial state. Use this for:

  • Reading from localStorage or sessionStorage
  • Computing initial state from props that requires non-trivial work
  • Constructing sets or maps from arrays
tsx
interface InvoiceFormState {
  lineItems: LineItem[];
  discount: number;
  notes: string;
}

function InvoiceForm({ draftInvoice }: { draftInvoice: Draft | null }) {
  const [formState, setFormState] = useState<InvoiceFormState>(() => {
    if (draftInvoice) {
      return {
        lineItems: draftInvoice.lineItems,
        discount: draftInvoice.discount,
        notes: draftInvoice.notes,
      };
    }
    return { lineItems: [], discount: 0, notes: '' };
  });
  // ...
}

Stale State in Callbacks#

This is the bug category that causes the most confusion in production. When you create a function inside a component — an event handler, a timeout callback, a Promise resolver — it captures the state values from the render in which it was created.

tsx
// Demonstrates the stale closure bug
function AutoSaveEditor() {
  const [content, setContent] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log('Auto-saving:', content); // Always logs '' !
      api.saveDraft(content); // always saves empty string
    }, 5000);

    return () => clearTimeout(timer);
  }, []); // Bug: content is missing from deps

  return (
    <textarea
      value={content}
      onChange={e => setContent(e.target.value)}
    />
  );
}

The fix is to add content to the dependency array so the effect re-creates when the content changes. We will cover the full useEffect patterns in the next post — the point here is that the stale value came from the closure capturing an old snapshot.

When to Split State#

Should you store everything in one object or use separate useState calls?

My heuristic: group values that change together, separate values that change independently.

tsx
// Good: these three values always change together
const [fetchState, setFetchState] = useState<{
  isLoading: boolean;
  data: Report | null;
  error: string | null;
}>({ isLoading: false, data: null, error: null });

// Also good: these change independently
const [sortField, setSortField] = useState<'date' | 'amount' | 'description'>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [currentPage, setCurrentPage] = useState(1);

If you find yourself constantly spreading and replacing an object to update one field, that object's fields probably want to be separate state variables.

In my experience, most useState bugs in production fall into one of three categories:

  1. Direct state mutation (modifying an object without creating a new reference)
  2. Missing the updater function when new state depends on previous state
  3. Stale closures in callbacks that capture old state values

The first two are preventable by habit. The third is solved by understanding useEffect dependencies, which we will cover thoroughly next time.

I use separate useState calls for independent values and a single object state for tightly coupled values. I always use the updater function form when the new value depends on the old value. And I rely on lazy initialization any time I need to read from browser APIs or do non-trivial computation to determine the starting state.

Next up: useEffect, the hook that has caused me more late nights than any other.