XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksReactReact Hooks Deep Dive

React Hooks Deep Dive

hooksuseStateuseEffectuseRefhandbook

TL;DR

  • Hooks let you use state and lifecycle in function components.
  • useState for local state, useEffect for side effects, useRef for mutable refs.
  • useReducer for complex state logic, useContext for prop drilling avoidance.
  • Rules: only call at top level, only in React functions.

Step 1: useState — State Management

Before hooks (pre-2019), you needed class components for any stateful logic — even a simple counter required a constructor, this.state, and this.setState. useState was created so function components could own state without the ceremony of classes. It's the foundation of every React app: form inputs, toggles, counters, selected items, loading flags. Understanding its gotchas (stale closures, batching, object immutability) is essential because useState bugs are the #1 source of React bugs in production.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0); // Initial value: 0

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(prev => prev - 1)}>Decrement</button>
    </div>
  );
}

Lazy Initialization

// ❌ Runs expensive function on EVERY render
const [data, setData] = useState(expensiveComputation());

// ✅ Runs expensive function only on FIRST render
const [data, setData] = useState(() => expensiveComputation());

Object State

const [form, setForm] = useState({ name: '', email: '' });

// ❌ Mutates directly — won't trigger re-render
form.name = 'John';

// ✅ Create new object (spread previous state)
setForm(prev => ({ ...prev, name: 'John' }));

Key Gotcha: Stale Closures

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      // ❌ count is always 0 (stale closure)
      setCount(count + 1);

      // ✅ Use functional updater — always has latest value
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);
}

Step 2: useEffect — Side Effects

React components are meant to be pure functions of state — but real apps need side effects: fetching data, subscribing to events, manipulating the DOM, setting up timers. Before hooks, lifecycle methods (componentDidMount, componentWillUnmount) scattered related logic across a class. useEffect was invented to colocate a side effect with its cleanup in one place, and to tie it to specific dependencies so it re-runs only when needed. Misunderstanding the dependency array is the most common source of bugs for React beginners.

import { useEffect, useState } from 'react';

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false; // Prevent setting state after unmount

    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      if (!cancelled) setUser(data);
    }

    fetchUser();

    // Cleanup function — runs on unmount or before next effect
    return () => { cancelled = true; };
  }, [userId]); // Re-run when userId changes

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}

Dependency Array Patterns

// Runs on EVERY render (rarely what you want)
useEffect(() => { ... });

// Runs ONCE on mount (empty deps)
useEffect(() => { ... }, []);

// Runs when `a` or `b` change
useEffect(() => { ... }, [a, b]);

Common Cleanup Patterns

// Event listeners
useEffect(() => {
  const handler = (e: KeyboardEvent) => { /* ... */ };
  window.addEventListener('keydown', handler);
  return () => window.removeEventListener('keydown', handler);
}, []);

// Subscriptions
useEffect(() => {
  const subscription = someObservable.subscribe(value => {
    setValue(value);
  });
  return () => subscription.unsubscribe();
}, []);

// Timers
useEffect(() => {
  const timer = setTimeout(() => setVisible(true), 1000);
  return () => clearTimeout(timer);
}, []);

Step 3: useRef — Mutable References

useRef solves two problems that useState can't: (1) accessing the actual DOM node (for focus, measurement, or third-party library integration), and (2) storing mutable values that persist across renders without triggering a re-render. It's React's "escape hatch" to the imperative world. You'll use it for interval/timeout IDs, previous value tracking, tracking whether a component is mounted, and any value that needs to survive re-renders but shouldn't cause them.

import { useRef, useEffect } from 'react';

// Use case 1: DOM references
function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus(); // Access DOM element directly
  }, []);

  return <input ref={inputRef} />;
}

// Use case 2: Persist values across renders (without re-rendering)
function StopWatch() {
  const [elapsed, setElapsed] = useState(0);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  function start() {
    intervalRef.current = setInterval(() => {
      setElapsed(prev => prev + 1);
    }, 1000);
  }

  function stop() {
    if (intervalRef.current) clearInterval(intervalRef.current);
  }

  return (
    <div>
      <p>{elapsed}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

// Use case 3: Previous value tracking
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

Step 4: useReducer — Complex State

useReducer exists because useState becomes unwieldy when state has multiple sub-values that change together (loading + data + error), or when the next state depends on the current state in complex ways. Inspired by Redux's reducer pattern, it centralizes state transitions into a pure function, making logic predictable and testable. Use it for form wizards, data fetching states, shopping carts, or any state machine where transitions should be explicit and named.

import { useReducer } from 'react';

type State = {
  items: string[];
  loading: boolean;
  error: string | null;
};

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: string[] }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'ADD_ITEM'; payload: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, items: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    loading: false,
    error: null,
  });

  async function fetchItems() {
    dispatch({ type: 'FETCH_START' });
    try {
      const data = await fetch('/api/todos').then(r => r.json());
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (e) {
      dispatch({ type: 'FETCH_ERROR', payload: 'Failed to fetch' });
    }
  }

  return (
    <div>
      {state.loading && <p>Loading...</p>}
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state.items.map(item => <li key={item}>{item}</li>)}
    </div>
  );
}

Step 5: useContext — Avoid Prop Drilling

Context was created to solve "prop drilling" — passing data through 5+ levels of components that don't use it themselves, just to reach a deeply nested child. Without Context, changing a theme or locale meant threading props through every intermediate component. useContext made consuming context values trivial (one line instead of render props), and it's the foundation for most "global" state in React: themes, auth, localization, and feature flags. The tradeoff is that all consumers re-render when the value changes — which is why it's best for infrequently-changing values.

import { createContext, useContext, useState, ReactNode } from 'react';

// 1. Create context with type
type Theme = 'light' | 'dark';
type ThemeContextType = {
  theme: Theme;
  toggle: () => void;
};

const ThemeContext = createContext<ThemeContextType | null>(null);

// 2. Provider component
function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. Custom hook for consuming (type-safe)
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}

// 4. Usage
function Header() {
  const { theme, toggle } = useTheme();
  return (
    <header className={theme === 'dark' ? 'bg-black' : 'bg-white'}>
      <button onClick={toggle}>Toggle Theme</button>
    </header>
  );
}

Step 6: Custom Hooks

Custom hooks were the real breakthrough of the hooks API — they let you extract and share stateful logic between components without render props or HOCs. Before hooks, sharing logic like "debounced input" or "window size tracking" required wrapping components in higher-order components, creating deeply nested trees. Custom hooks are just functions that call other hooks, giving you composition instead of inheritance. Every production React app has a hooks/ folder full of reusable logic: useDebounce, useLocalStorage, useMediaQuery, useIntersectionObserver.

// Debounce hook
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Local storage hook
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Window size hook
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const handler = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight,
    });
    handler(); // Set initial
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return size;
}

// Usage
function SearchPage() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  const { width } = useWindowSize();

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);
}

Interview Questions

  1. What's the difference between useEffect and useLayoutEffect?

    • useEffect fires asynchronously after paint (non-blocking). useLayoutEffect fires synchronously after DOM mutations but before paint — use it for measuring DOM or preventing visual flicker.
  2. Why can't hooks be called conditionally?

    • React relies on call ORDER to match hooks to their state. Conditional calls change the order between renders, breaking the association.
  3. When would you use useReducer over useState?

    • When state transitions are complex (multiple sub-values, next state depends on previous), when state logic is shared across components, or when you want predictable state machines.
  4. How do you prevent unnecessary re-renders with hooks?

    • Use React.memo for components, useMemo for expensive calculations, useCallback for stable function references. In React 19, the Compiler handles most of this automatically.
React 19 — New Features & Patterns

On this page

  • TL;DR
  • Step 1: useState — State Management
  • Lazy Initialization
  • Object State
  • Key Gotcha: Stale Closures
  • Step 2: useEffect — Side Effects
  • Dependency Array Patterns
  • Common Cleanup Patterns
  • Step 3: useRef — Mutable References
  • Step 4: useReducer — Complex State
  • Step 5: useContext — Avoid Prop Drilling
  • Step 6: Custom Hooks
  • Interview Questions