Skip to content

React Hooks Patterns: Beyond useState and useEffect

Custom hooks, composition patterns and the rules that govern them: practical React hooks for complex state and side effects without class components.

3 min read
React hooks composition diagram showing custom hooks wrapping useState and useEffect

Hooks replaced class components as the standard way to manage state and side effects in React. But beyond useState and useEffect, there's a rich set of patterns for extracting logic into reusable custom hooks, managing complex state transitions, and avoiding the common pitfalls that lead to stale closures and infinite re-renders.

Custom Hooks: Extracting Reusable Logic

A custom hook is a function that uses other hooks. It encapsulates stateful logic that can be shared across components.

tsxtsx
// ❌ Duplicated fetch logic in every component
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);
 
  // Same pattern copied into OrderList, ProductPage, etc.
}
 
// ✅ Extracted into a reusable hook
function useFetch<T>(url: string): { data: T | null; loading: boolean; error: Error | null } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);
 
    fetch(url, { signal: controller.signal })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== "AbortError") setError(err);
      })
      .finally(() => setLoading(false));
 
    return () => controller.abort();
  }, [url]);
 
  return { data, loading, error };
}
 
// Clean usage in any component
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
 
  if (loading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  return <ProfileCard user={user!} />;
}

useReducer for Complex State

When state transitions depend on the current state or involve multiple related values, useReducer is clearer than multiple useState calls.

tsxtsx
// ❌ Multiple useState calls with coupled updates
function OrderForm() {
  const [items, setItems] = useState<OrderItem[]>([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);
  const [status, setStatus] = useState<"idle" | "submitting" | "error">("idle");
 
  function addItem(item: OrderItem) {
    setItems([...items, item]);
    setTotal(total + item.price);
    // Easy to forget updating total when removing items
  }
}
 
// ✅ useReducer keeps related state transitions consistent
type OrderAction =
  | { type: "ADD_ITEM"; item: OrderItem }
  | { type: "REMOVE_ITEM"; itemId: string }
  | { type: "APPLY_DISCOUNT"; percentage: number }
  | { type: "SUBMIT" }
  | { type: "SUBMIT_ERROR"; error: string };
 
interface OrderState {
  items: OrderItem[];
  total: number;
  discount: number;
  status: "idle" | "submitting" | "error";
  error: string | null;
}
 
function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "ADD_ITEM":
      return {
        ...state,
        items: [...state.items, action.item],
        total: state.items.reduce((sum, i) => sum + i.price, 0) + action.item.price,
      };
    case "REMOVE_ITEM":
      const filtered = state.items.filter(i => i.id !== action.itemId);
      return {
        ...state,
        items: filtered,
        total: filtered.reduce((sum, i) => sum + i.price, 0),
      };
    case "APPLY_DISCOUNT":
      return { ...state, discount: action.percentage };
    case "SUBMIT":
      return { ...state, status: "submitting", error: null };
    case "SUBMIT_ERROR":
      return { ...state, status: "error", error: action.error };
    default:
      return state;
  }
}

useCallback and useMemo: Avoiding Unnecessary Work

Memoize callbacks to prevent child component re-renders and expensive computations from running on every render.

tsxtsx
// ❌ New function reference on every render → child re-renders
function ParentComponent({ items }: { items: Item[] }) {
  const handleSelect = (id: string) => {
    console.log("Selected:", id);
  };
 
  // filteredItems recalculated on every render, even if items hasn't changed
  const filteredItems = items.filter(i => i.active);
 
  return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
 
// ✅ Stable references with useCallback and useMemo
function ParentComponent({ items }: { items: Item[] }) {
  const handleSelect = useCallback((id: string) => {
    console.log("Selected:", id);
  }, []);
 
  const filteredItems = useMemo(
    () => items.filter(i => i.active),
    [items]
  );
 
  return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
 
// Only memoize the child if it's expensive to render
const ItemList = React.memo(function ItemList({
  items,
  onSelect,
}: {
  items: Item[];
  onSelect: (id: string) => void;
}) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

useRef for Persistent Values Without Re-renders

Refs hold mutable values that persist across renders without causing re-renders when they change.

tsxtsx
function useInterval(callback: () => void, delayMs: number) {
  const savedCallback = useRef(callback);
 
  // Update the ref when callback changes — no re-render needed
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
 
  useEffect(() => {
    const id = setInterval(() => savedCallback.current(), delayMs);
    return () => clearInterval(id);
  }, [delayMs]);
}
 
// Usage: callback can change without resetting the interval
function PollComponent() {
  const [count, setCount] = useState(0);
 
  useInterval(() => {
    setCount(c => c + 1); // Always uses latest count
  }, 1000);
 
  return <div>Count: {count}</div>;
}

Avoiding Stale Closures

The most common hooks bug: a callback captures an old value from a previous render.

tsxtsx
// ❌ Stale closure — count is always 0 inside the timeout
function Timer() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setTimeout(() => {
      console.log(`Count is: ${count}`); // Always logs 0
    }, 5000);
    return () => clearTimeout(id);
  }, []); // Empty deps → closure captures initial count (0)
 
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
 
// ✅ Use a ref to read the latest value
function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;
 
  useEffect(() => {
    const id = setTimeout(() => {
      console.log(`Count is: ${countRef.current}`); // Reads current value
    }, 5000);
    return () => clearTimeout(id);
  }, []);
 
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Key Takeaways

  1. Custom hooks extract reusable stateful logic — share behavior across components without render props or HOCs
  2. useReducer handles complex state transitions — keep related state updates consistent in a single reducer
  3. Memoize strategically — useCallback and useMemo prevent unnecessary work, but only when the child actually uses React.memo
  4. useRef for values that shouldn't trigger re-renders — timer IDs, DOM references, and latest callback references
  5. Watch for stale closures — effects with empty dependency arrays capture initial values, use refs to read current values
  6. Follow the rules of hooks — always call hooks at the top level, never inside conditions or loops
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX