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
-
What's the difference between useEffect and useLayoutEffect?
useEffectfires asynchronously after paint (non-blocking).useLayoutEffectfires synchronously after DOM mutations but before paint — use it for measuring DOM or preventing visual flicker.
-
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.
-
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.
-
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.