React Hooks Deep Dive: useState, useEffect, useCallback, useMemo & Custom Hooks
A complete guide to React hooks with real-world patterns — from basic useState to writing production-grade custom hooks for data fetching, forms, and UI state.
Why Hooks Changed Everything
Before hooks (pre-React 16.8), reusing stateful logic required class components and patterns like HOCs or render props — which created wrapper hell and made code hard to follow. Hooks let you extract and reuse stateful logic without changing your component hierarchy.
The two rules that everything else flows from:
- Only call hooks at the top level — not inside conditions, loops, or nested functions
- Only call hooks from React functions — components or other hooks
useState: Beyond the Basics
// Basic: single value
const [count, setCount] = useState(0);
// Object state: common mistake vs correct approach
// ❌ Wrong: replaces entire state object
setState({ name: "Alice" }); // loses other fields
// ✅ Correct: merge with spread
setState((prev) => ({ ...prev, name: "Alice" }));Real-World: Form State
function RegistrationForm() {
const [form, setForm] = useState({
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
});
const [errors, setErrors] = useState({});
const updateField = (field) => (e) => {
const value = e.target.type === "checkbox" ? e.target.checked : e.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
// Clear error when user types
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!form.email.includes("@")) newErrors.email = "Invalid email";
if (form.password.length < 8) newErrors.password = "Min 8 characters";
if (form.password !== form.confirmPassword)
newErrors.confirmPassword = "Passwords don't match";
if (!form.agreeToTerms) newErrors.agreeToTerms = "Required";
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length) {
setErrors(validationErrors);
return;
}
await registerUser(form);
};
return (
<form onSubmit={handleSubmit}>
<Input
label="Email"
type="email"
value={form.email}
onChange={updateField("email")}
error={errors.email}
/>
<Input
label="Password"
type="password"
value={form.password}
onChange={updateField("password")}
error={errors.password}
/>
{/* ... */}
</form>
);
}Lazy Initialization — For Expensive Default Values
// ❌ This runs on EVERY render, not just the first
const [filters, setFilters] = useState(loadFiltersFromStorage());
// ✅ Pass a function — React only calls it once on mount
const [filters, setFilters] = useState(() => loadFiltersFromStorage());useReducer: When useState Gets Messy
Use useReducer when state transitions are complex or state depends on previous state in multiple ways:
const initialState = {
items: [],
loading: false,
error: null,
page: 1,
hasMore: true,
};
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id
? { ...i, qty: i.qty + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, qty: 1 }],
};
case "REMOVE_ITEM":
return {
...state,
items: state.items.filter((i) => i.id !== action.payload),
};
case "UPDATE_QTY":
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id
? { ...i, qty: Math.max(0, action.payload.qty) }
: i
).filter((i) => i.qty > 0),
};
case "CLEAR":
return { ...state, items: [] };
default:
return state;
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<div>
{state.items.map((item) => (
<CartItem
key={item.id}
item={item}
onRemove={() => dispatch({ type: "REMOVE_ITEM", payload: item.id })}
onUpdateQty={(qty) =>
dispatch({ type: "UPDATE_QTY", payload: { id: item.id, qty } })
}
/>
))}
<button onClick={() => dispatch({ type: "CLEAR" })}>Clear Cart</button>
</div>
);
}useEffect: The Correct Mental Model
Stop thinking of useEffect as lifecycle methods. Think of it as synchronizing with external systems.
useEffect(() => {
// Run when dependencies change — start synchronizing
return () => {
// Run before next effect or unmount — stop synchronizing
};
}, [dependencies]);Dependency Array Patterns
useEffect(() => { /* ... */ }); // Every render (rarely what you want)
useEffect(() => { /* ... */ }, []); // Mount only
useEffect(() => { /* ... */ }, [id]); // When id changes
useEffect(() => { /* ... */ }, [id, status]); // When either changesReal-World: Data Fetching with Cleanup
function ProductDetails({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// AbortController lets us cancel the fetch if productId changes
const controller = new AbortController();
setLoading(true);
setError(null);
async function fetchProduct() {
try {
const res = await fetch(`/api/products/${productId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setProduct(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [productId]);
if (loading) return <ProductSkeleton />;
if (error) return <ErrorMessage message={error} />;
return <ProductView product={product} />;
}Real-World: WebSocket Subscription
function LiveOrderStatus({ orderId }) {
const [status, setStatus] = useState("pending");
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/orders/${orderId}`);
ws.onmessage = (event) => {
const { status } = JSON.parse(event.data);
setStatus(status);
};
ws.onerror = () => setStatus("error");
return () => ws.close();
}, [orderId]);
return <StatusBadge status={status} />;
}Real-World: Event Listeners
function useKeyboardShortcut(key, callback) {
useEffect(() => {
const handler = (e) => {
if (e.key === key && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
callback();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [key, callback]);
}
// Usage
function Editor() {
const handleSave = useCallback(() => saveDocument(), []);
useKeyboardShortcut("s", handleSave);
// ...
}useRef: Beyond DOM Access
useRef gives you a mutable container that persists across renders without triggering re-renders.
// 1. DOM access
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Search..." />;
}
// 2. Storing previous values
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// 3. Tracking mounted state (avoids setState after unmount)
function DataLoader({ url }) {
const isMounted = useRef(true);
const [data, setData] = useState(null);
useEffect(() => {
return () => { isMounted.current = false; };
}, []);
useEffect(() => {
fetchData(url).then((data) => {
if (isMounted.current) setData(data);
});
}, [url]);
}
// 4. Storing interval/timeout IDs
function Countdown({ seconds }) {
const [remaining, setRemaining] = useState(seconds);
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
};
const stop = () => clearInterval(intervalRef.current);
return (
<div>
<span>{remaining}s</span>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}useCallback and useMemo: When They Actually Help
These are optimization hooks. Only use them when you have a measurable performance problem.
useCallback — Stable Function References
// Problem: every render creates a new function reference
function ParentList({ items }) {
// ❌ handleDelete is a new function every render
// Child components that use React.memo will re-render anyway
const handleDelete = (id) => deleteItem(id);
// ✅ Stable reference — only changes when deleteItem changes
const handleDelete = useCallback(
(id) => deleteItem(id),
[deleteItem]
);
return items.map((item) => (
<MemoizedItem key={item.id} item={item} onDelete={handleDelete} />
));
}useMemo — Expensive Computations
function ReportDashboard({ transactions, dateRange, category }) {
// ❌ Runs on every render, even when transactions haven't changed
const summary = computeExpensiveSummary(transactions, dateRange, category);
// ✅ Only recomputes when dependencies change
const summary = useMemo(
() => computeExpensiveSummary(transactions, dateRange, category),
[transactions, dateRange, category]
);
// Common use: filtering large datasets
const filtered = useMemo(
() =>
transactions.filter(
(t) =>
t.category === category &&
t.date >= dateRange.start &&
t.date <= dateRange.end
),
[transactions, category, dateRange.start, dateRange.end]
);
return <SummaryChart data={summary} transactions={filtered} />;
}When NOT to use these hooks:
- Simple state updates
- Cheap computations
- Primitives (strings, numbers) — they're already compared by value
useContext — Avoiding Prop Drilling
// 1. Create the context
interface AuthContextType {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
// 2. Create a provider
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check existing session on mount
getCurrentUser()
.then(setUser)
.finally(() => setIsLoading(false));
}, []);
const login = async (credentials) => {
const user = await signIn(credentials);
setUser(user);
};
const logout = async () => {
await signOut();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
// 3. Create a custom hook (always do this — don't use useContext directly)
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
// 4. Use anywhere in the tree
function UserMenu() {
const { user, logout } = useAuth();
return (
<div>
<span>{user?.name}</span>
<button onClick={logout}>Sign Out</button>
</div>
);
}Custom Hooks: The Real Power of Hooks
Custom hooks let you extract and share stateful logic. By convention, they start with use.
useLocalStorage
function useLocalStorage(key, initialValue) {
const [stored, setStored] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
try {
const toStore = value instanceof Function ? value(stored) : value;
setStored(toStore);
window.localStorage.setItem(key, JSON.stringify(toStore));
} catch (error) {
console.error(error);
}
};
return [stored, setValue];
}
// Usage
function ThemeSettings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}useDebounce — For Search Inputs
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage: Search that doesn't fire on every keystroke
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchProducts(debouncedQuery).then(setResults);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
);
}useFetch — Data Fetching Pattern
function useFetch(url, options = {}) {
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { ...options, signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
})
.then((data) => setState({ data, loading: false, error: null }))
.catch((err) => {
if (err.name !== "AbortError") {
setState({ data: null, loading: false, error: err.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
// Usage
function UserProfile({ id }) {
const { data: user, loading, error } = useFetch(`/api/users/${id}`);
if (loading) return <Skeleton />;
if (error) return <Alert message={error} />;
return <ProfileCard user={user} />;
}useIntersectionObserver — Infinite Scroll
function useIntersectionObserver(ref, options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [ref, options.threshold, options.root]);
return isIntersecting;
}
// Usage: Load more when sentinel element is visible
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const sentinelRef = useRef(null);
const isVisible = useIntersectionObserver(sentinelRef, { threshold: 0.1 });
useEffect(() => {
if (isVisible) {
fetchItems(page).then((newItems) => {
setItems((prev) => [...prev, ...newItems]);
setPage((p) => p + 1);
});
}
}, [isVisible]);
return (
<div>
{items.map((item) => <ItemCard key={item.id} item={item} />)}
<div ref={sentinelRef} style={{ height: 1 }} />
</div>
);
}Interview Questions Answered
Q: What is the difference between useEffect and useLayoutEffect?
useEffect runs asynchronously after the browser has painted. useLayoutEffect runs synchronously after DOM mutations but before painting. Use useLayoutEffect only when you need to measure the DOM or prevent visual flicker (tooltip positioning, scroll restoration). Default to useEffect.
Q: Why does useEffect sometimes run twice in development?
React 18 Strict Mode mounts components twice to help detect side effects that aren't properly cleaned up. This only happens in development. Your cleanup function (the return value of useEffect) should fully undo what the effect set up.
Q: When would you choose useReducer over useState?
When: (1) state has multiple sub-values that change together, (2) next state depends on complex logic from previous state, (3) you want to co-locate state transitions with the component (like a mini state machine). Classic example: a multi-step form with validation state.
Q: Does useCallback always prevent re-renders?
No. useCallback gives you a stable function reference. But child components still re-render unless they're wrapped in React.memo. You need both: useCallback for the reference stability + React.memo on the child to actually skip the re-render.
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.