React Hooks, introduced in React 16.8, fundamentally changed how we write React components. They let you use state and other React features in functional components — no classes required. This guide covers every built-in hook with real examples, common patterns, and the pitfalls to avoid.
Why Hooks?
Before hooks, stateful logic was locked inside class components, which led to several problems: complex lifecycle methods that forced unrelated logic together, difficulty reusing stateful logic between components, and confusing this bindings. Hooks solve all of these by letting you extract stateful logic into reusable functions.
useState
The most fundamental hook. useState adds state to a functional component and returns the current value plus a function to update it.
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)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Functional Updates
When the new state depends on the previous state, use the functional form of the setter. This is important in async contexts where the state value might be stale.
// ❌ Potentially stale
setCount(count + 1);
// ✅ Always uses the latest state
setCount(prevCount => prevCount + 1);
// Practical example: toggling
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
Objects and Arrays in State
// Always replace, never mutate
const [user, setUser] = useState({ name: 'Alice', age: 30 });
// ❌ Wrong — mutates state directly
user.age = 31;
setUser(user);
// ✅ Correct — create a new object
setUser(prev => ({ ...prev, age: 31 }));
// Array state
const [items, setItems] = useState([]);
const addItem = (item) => setItems(prev => [...prev, item]);
const removeItem = (id) => setItems(prev => prev.filter(i => i.id !== id));
const updateItem = (id, changes) => setItems(prev =>
prev.map(i => i.id === id ? { ...i, ...changes } : i)
);
Lazy Initial State
// If initial state is expensive to compute, pass a function
// The function only runs once on mount
const [data, setData] = useState(() => {
const stored = localStorage.getItem('data');
return stored ? JSON.parse(stored) : defaultData;
});
useEffect
useEffect lets you perform side effects in functional components: data fetching, subscriptions, DOM manipulation, timers.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// This runs after every render where userId changed
let cancelled = false;
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) setUser(data);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
// Cleanup function: runs when component unmounts or userId changes
return () => { cancelled = true; };
}, [userId]); // dependency array: re-run when userId changes
if (loading) return <p>Loading...</p>;
return <p>{user?.name}</p>;
}
Effect Dependencies
// Run on every render (no dependency array)
useEffect(() => { console.log('rendered'); });
// Run only once on mount (empty dependency array)
useEffect(() => {
initAnalytics();
return () => cleanup();
}, []);
// Run when specific values change
useEffect(() => {
document.title = `${count} notifications`;
}, [count]);
// Cleanup patterns
useEffect(() => {
const subscription = subscribe(topic, handler);
return () => subscription.unsubscribe(); // runs on unmount
}, [topic]);
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useContext
useContext lets you consume React context without nesting render props or consumer components.
import { createContext, useContext, useState } from 'react';
// 1. Create context
const ThemeContext = createContext('light');
// 2. Provide context
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Main />
</ThemeContext.Provider>
);
}
// 3. Consume context anywhere in the tree
function ThemedButton() {
const { theme, setTheme } = useContext(ThemeContext);
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<button
style={{ background: theme === 'light' ? '#fff' : '#333' }}
onClick={toggle}
>
Current: {theme}
</button>
);
}
// Custom hook for context (recommended pattern)
function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be inside ThemeProvider');
return context;
}
useRef
useRef returns a mutable ref object that persists across renders without causing re-renders. Use it for DOM references and storing mutable values.
import { useRef, useEffect } from 'react';
function TextInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // auto-focus on mount
}, []);
return <input ref={inputRef} type="text" />;
}
// Store previous value
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current; // returns previous value before this render
}
// Store a mutable value that doesn't trigger re-renders
function Timer() {
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
const start = () => {
setIsRunning(true);
intervalRef.current = setInterval(() => tick(), 1000);
};
const stop = () => {
setIsRunning(false);
clearInterval(intervalRef.current);
};
return (
<div>
{isRunning
? <button onClick={stop}>Stop</button>
: <button onClick={start}>Start</button>
}
</div>
);
}
useMemo
useMemo memoizes an expensive computation, only recalculating when dependencies change.
import { useMemo } from 'react';
function ProductList({ products, filter, sortBy }) {
// Only recomputed when products, filter, or sortBy changes
const processedProducts = useMemo(() => {
return products
.filter(p => p.category === filter || filter === 'all')
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
}, [products, filter, sortBy]);
return (
<ul>
{processedProducts.map(p => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
);
}
Don't overuse useMemo. The memoization itself has a cost. Only use it for genuinely expensive computations (complex data transformations, sorting large arrays) or when referential equality matters for child component re-renders.
useCallback
useCallback memoizes a function itself. It's most useful when passing callbacks to optimized child components that rely on reference equality to skip re-renders.
import { useCallback, memo } from 'react';
// Child component wrapped in memo — only re-renders if props change
const Button = memo(({ onClick, children }) => {
console.log('Button rendered');
return <button onClick={onClick}>{children}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback: new function reference on every render
// → Button re-renders every time text changes
// With useCallback: stable reference unless count changes
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []); // no dependencies — function never changes
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<Button onClick={handleClick}>Count: {count}</Button>
</div>
);
}
useReducer
useReducer is an alternative to useState for complex state logic with multiple sub-values or when the next state depends on the previous state in non-trivial ways.
import { useReducer } from 'react';
const initialState = {
items: [],
total: 0,
loading: false,
error: null,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const removed = state.items.find(i => i.id === action.payload);
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
total: state.total - (removed?.price || 0),
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'CLEAR':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const addToCart = (product) =>
dispatch({ type: 'ADD_ITEM', payload: product });
const removeFromCart = (id) =>
dispatch({ type: 'REMOVE_ITEM', payload: id });
return (
<div>
<h2>Cart ({state.items.length} items)</h2>
<p>Total: ${state.total.toFixed(2)}</p>
</div>
);
}
Custom Hooks
Custom hooks let you extract and reuse stateful logic. A custom hook is just a function that starts with use and calls other hooks.
// useFetch — data fetching with loading/error states
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => { if (!cancelled) setData(data); })
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// useLocalStorage — persist state to localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = (newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue];
}
// useDebounce — debounce a value
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// useWindowSize — track window dimensions
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
Rules of Hooks
React enforces two rules (and the ESLint plugin eslint-plugin-react-hooks enforces them automatically):
- Only call hooks at the top level. Never inside loops, conditions, or nested functions. React relies on the order of hook calls to associate state correctly.
- Only call hooks from React functions. Call them from React functional components or from custom hooks.
// ❌ Wrong — conditional hook call
function Component({ condition }) {
if (condition) {
const [val, setVal] = useState(0); // breaks rules!
}
}
// ✅ Correct — condition inside the hook logic
function Component({ condition }) {
const [val, setVal] = useState(0);
const displayVal = condition ? val : null;
}
Quick Reference
useState(initial) // local state
useEffect(fn, deps) // side effects, subscriptions
useContext(Context) // consume context
useRef(initialValue) // DOM refs, mutable values
useMemo(() => value, deps) // memoize expensive computation
useCallback(fn, deps) // memoize function reference
useReducer(reducer, init) // complex state logic
useId() // generate unique IDs (React 18)
useTransition() // mark updates as non-urgent (React 18)
useDeferredValue(value) // defer re-rendering (React 18)
useLayoutEffect(fn, deps) // like useEffect but synchronous
useImperativeHandle // customize ref values
useDebugValue // custom label in DevTools
Hooks take a bit of time to get used to — especially understanding when to use useEffect correctly — but once they click, they make React development significantly more pleasant. Start with useState and useEffect, then add useContext and useRef as you need them. Build custom hooks for anything you find yourself copy-pasting between components.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)