Introduction
Hooks changed React forever. Class components vanished from new codebases, lifecycle methods became one-liners, and reusable logic finally got a name: custom hooks. But there's a catch — most tutorials show you the syntax without explaining when to reach for each hook.
This guide walks through the six hooks you'll actually use 95% of the time, plus how to build your own:
useState— local component stateuseEffect— side effects and lifecycleuseRef— DOM access and mutable valuesuseMemoanduseCallback— performance optimizationuseContext— shared state without prop drilling- Custom hooks — packaging reusable logic
Every example is real code you'd write in a production app.
1. useState — Local Component State
The simplest hook. Use it for any value that should re-render the component when it changes:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
The functional updater pattern — use this when the new state depends on the previous state. It's safer because React batches updates:
// Wrong — may use stale state
setCount(count + 1);
setCount(count + 1); // still increments by 1, not 2
// Right — always uses the latest value
setCount(prev => prev + 1);
setCount(prev => prev + 1); // increments by 2
For objects and arrays, always create a new reference — React uses reference equality to detect changes:
// Wrong — mutating the existing object
user.name = 'New name';
setUser(user); // React won't re-render
// Right — new object
setUser({ ...user, name: 'New name' });
2. useEffect — Side Effects and Lifecycle
useEffect handles anything that touches the world outside React: API calls, subscriptions, timers, DOM manipulation. The dependency array controls when it runs:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
return () => { cancelled = true; }; // cleanup
}, [userId]); // re-run when userId changes
if (loading) return <p>Loading…</p>;
return <h1>{user.name}</h1>;
}
Three patterns to remember:
| Dependency array | Runs |
|---|---|
[] |
Only once, on mount |
[userId, filter] |
On mount + whenever any listed value changes |
| (omitted) | After every render — almost always a mistake |
The cleanup function (the return () => ...) runs before the next effect and on unmount. Use it to cancel requests, unsubscribe, or clear timers — anything that would leak otherwise.
3. useRef — DOM Access and Mutable Values
useRef has two distinct jobs. First, accessing DOM nodes directly:
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // focus on mount
}, []);
return <input ref={inputRef} placeholder="Type here…" />;
}
Second, holding mutable values that don't trigger re-renders — useful for things like interval IDs, previous values, or render counters:
import { useRef, useState, useEffect } from 'react';
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => () => clearInterval(intervalRef.current), []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Rule of thumb: if changing a value should re-render → useState. If it shouldn't → useRef.
4. useMemo and useCallback — Performance Optimization
These two hooks cache values between renders. Don't use them by default — only when you've measured a real performance problem.
useMemo caches the result of an expensive calculation:
import { useMemo, useState } from 'react';
function ProductList({ products }) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
console.log('Filtering…');
return products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, search]); // recompute only when these change
return (
<>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
</>
);
}
useCallback caches a function reference — useful when passing callbacks to memoized child components:
import { useCallback, useState, memo } from 'react';
const ExpensiveButton = memo(({ onClick, label }) => {
console.log(`Rendering ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// Without useCallback, this function is recreated every render,
// breaking memo() on ExpensiveButton.
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<p>Count: {count}</p>
<ExpensiveButton onClick={increment} label="Add" />
</>
);
}
When NOT to use these: tiny calculations, primitive values, components without memo. The bookkeeping cost can be higher than the calculation itself.
5. useContext — Shared State Without Prop Drilling
Passing props through 5 layers of components is painful. useContext lets any descendant grab a value directly:
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook wrapping useContext for a cleaner API
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
function ThemedButton() {
const { theme, toggle } = useTheme();
return (
<button
onClick={toggle}
style={{
background: theme === 'dark' ? '#222' : '#eee',
color: theme === 'dark' ? '#fff' : '#000',
}}
>
Toggle ({theme})
</button>
);
}
export default function App() {
return (
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
}
Important: every consumer of a context re-renders whenever the context value changes. For global state that changes often (chat messages, live data), use a real state manager like Zustand or Redux. Use useContext for things that change rarely — theme, locale, current user.
6. Custom Hooks — Packaging Reusable Logic
When two components share the same stateful logic, extract a custom hook. The only rule: the function name must start with use.
Here's a useFetch hook you can drop into any project:
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { ...options, signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// Using it
function Posts() {
const { data, loading, error } = useFetch('/api/posts');
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Another useful one — useLocalStorage syncs a state value with localStorage:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
function Settings() {
const [name, setName] = useLocalStorage('username', '');
return <input value={name} onChange={e => setName(e.target.value)} />;
}
Now any component in your app can persist state with one line.
The Rules of Hooks (Don't Break These)
Two rules, enforced by the eslint-plugin-react-hooks plugin:
1. Only call hooks at the top level — never inside loops,
conditions, or nested functions.
2. Only call hooks from React functions — components or
other custom hooks. Not from regular functions.
These exist because React tracks hooks by call order. Skip a hook on one render and the next one gets the wrong state.
Hook Cheat Sheet
| Hook | Use it for |
|---|---|
useState |
Any value that should trigger a re-render |
useEffect |
API calls, subscriptions, timers, DOM side effects |
useRef |
DOM access, mutable values that shouldn't re-render |
useMemo |
Caching expensive calculations |
useCallback |
Stable function references for memoized children |
useContext |
Sharing rarely-changing state across the tree |
| Custom hooks | Reusing stateful logic between components |
Final Thought
You don't need to memorize every hook React ships with. Master these six and you can build 95% of any React app. The other hooks — useReducer, useLayoutEffect, useImperativeHandle, useTransition — are tools for specific problems you'll recognize when you hit them.
The best way to internalize hooks isn't reading more articles. Pick one of the examples above, copy it into a fresh React project, and break it on purpose. Move a useState inside an if and watch what happens. Skip a dependency in useEffect and watch the infinite loop. That's how the rules stop being rules and start being intuition.