State is data managed by a component that, when changed, causes React to re-render. useState is the primary hook for local state. useEffect handles synchronisation with external systems. Understanding when and why React re-renders — and how to control it — is fundamental to building correct React apps.

Key Points

  • useState: const [value, setValue] = useState(initial) — value is read-only; always update via setValue
  • State updates are asynchronous and batched — reading state immediately after setState returns the old value; use functional update form
  • Functional update: setValue(prev => prev + 1) — use when new state depends on old state, safe in async handlers
  • Each useState call creates isolated state — components can have as many as needed
  • Derived state: do NOT sync props to state with useEffect — compute it directly in the render function
  • useEffect(fn, deps): run side effects after render — data fetching, subscriptions, manual DOM manipulation
  • useEffect cleanup: return a cleanup function to cancel subscriptions, timers, or abort fetch on unmount
  • Dependency array: [] runs once on mount; [a, b] runs when a or b changes; omit → runs after every render (rarely correct)
  • Controlled vs uncontrolled: controlled = React owns value via useState; uncontrolled = DOM owns value via useRef

React state: functional updates, derived state without useEffect, fetch with AbortController cleanup

import { useState, useEffect } from 'react';

// Functional update — safe when new value depends on previous
function Counter() {
  const [count, setCount] = useState(0);
  const [history, setHistory] = useState<number[]>([]);

  function increment() {
    setCount(prev => prev + 1);                        // safe functional update
    setHistory(prev => [...prev, prev.length + 1]);   // append
  }

  return <button onClick={increment}>Count: {count}</button>;
}

// Derived state — compute in render, don't sync to state
function ProductList({ products }: { products: Product[] }) {
  const [filter, setFilter] = useState('');

  // Derive from props + state — no useEffect needed
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {filtered.map(p => <ProductCard key={p.id} product={p} />)}
    </>
  );
}

// useEffect — data fetching with cleanup (AbortController)
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
      .catch(e => { if (e.name !== 'AbortError') setError(e.message); })
      .finally(() => setLoading(false));

    return () => controller.abort();   // cleanup: cancel on userId change or unmount
  }, [userId]);

  if (loading) return <Spinner />;
  if (error)   return <ErrorMessage message={error} />;
  return user ? <UserCard user={user} /> : null;
}

Real-World Example

The most common React bug: reading state immediately after setState and expecting the new value. setState is asynchronous — the component re-renders after the current event handler completes. If you need the new value immediately, use functional update form or compute it into a local variable: const newCount = count + 1; setCount(newCount); doSomethingWith(newCount).