React Hooks Deep Dive — useEffect, useMemo, useCallback, useRef Explained
In this tutorial, you'll learn about React Hooks Deep Dive. We cover key concepts, practical examples, and best practices.
What You'll Learn
This deep dive covers four essential React hooks — useEffect for side effects, useMemo and useCallback for performance optimization, and useRef for mutable references — with advanced patterns and TypeScript examples.
Why It Matters
Mastering these hooks beyond the basics separates intermediate React developers from advanced ones. Misusing useEffect causes infinite loops and stale closures. Skipping useMemo and useCallback leads to unnecessary re-renders that tank performance in large applications.
Real-World Use
A dashboard app where useEffect fetches real-time stock data, useMemo memoizes expensive chart calculations, useCallback prevents the chart component from re-rendering on every parent update, and useRef captures the previous data set for comparison.
Learning Path
flowchart LR
A[React Basics] --> B[useState Hook]
B --> C[useEffect Hook]
C --> D[Hooks Deep Dive]
D --> E[Custom Hooks]
E --> F[Performance Optimization]
F --> G[Final Project]
D -->|You are here| D
useEffect — Side Effect Lifecycle
The useEffect hook manages side effects in React functional components. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount from class components.
import { useState, useEffect } from "react";
type User = { id: number; name: string; email: string };
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadUser() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Failed to fetch");
const data: User = await res.json();
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
}
loadUser();
return () => { cancelled = true; };
}, [userId]);
if (loading) return <div>Loading user...</div>;
if (error) return <div role="alert">Error: {error}</div>;
return <h1>{user?.name}</h1>;
}
Expected output: When userId changes, the component shows "Loading user...", fetches data, then displays the user name. If the component unmounts mid-request, the cleanup function prevents setting state on an unmounted component.
Effect Cleanup Patterns
import { useEffect, useRef, useState } from "react";
function ConnectionStatus({ url }: { url: string }) {
const [isConnected, setIsConnected] = useState(false);
const retryCount = useRef(0);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => setIsConnected(true);
ws.onclose = () => {
setIsConnected(false);
retryCount.current += 1;
};
ws.onerror = () => setIsConnected(false);
return () => {
ws.close();
};
}, [url]);
return (
<div>
Status: {isConnected ? "Connected" : "Disconnected"}
(retries: {retryCount.current})
</div>
);
}
Expected output: Renders "Status: Connected" or "Status: Disconnected" based on WebSocket state. When url changes or the component unmounts, the cleanup closes the old connection.
useMemo — Memoizing Expensive Computations
useMemo caches the result of a computation between re-renders. Only recomputes when dependencies change.
import { useMemo, useState } from "react";
interface Transaction {
id: number;
amount: number;
category: string;
date: string;
}
function SpendingReport({ transactions }: { transactions: Transaction[] }) {
const [currency, setCurrency] = useState("USD");
const totalsByCategory = useMemo(() => {
console.log("Computing category totals...");
return transactions.reduce<Record<string, number>>((acc, t) => {
acc[t.category] = (acc[t.category] || 0) + t.amount;
return acc;
}, {});
}, [transactions]);
const highestCategory = useMemo(() => {
return Object.entries(totalsByCategory).sort((a, b) => b[1] - a[1])[0];
}, [totalsByCategory]);
return (
<div>
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
<h2>Highest Spending: {highestCategory?.[0]}</h2>
<ul>
{Object.entries(totalsByCategory).map(([cat, total]) => (
<li key={cat}>{cat}: ${total.toFixed(2)}</li>
))}
</ul>
</div>
);
}
Expected output: The category totals are computed only when transactions changes. Changing the currency dropdown does NOT trigger re-computation. The console log appears only on transaction updates, demonstrating memoization at work.
useCallback — Stable Function References
useCallback returns a memoized version of a callback function. Essential when passing callbacks to child components that rely on reference equality to prevent re-renders.
import { useCallback, useState, memo } from "react";
const ExpensiveList = memo(function ExpensiveList({
items,
onSelect,
}: {
items: { id: number; label: string }[];
onSelect: (id: number) => void;
}) {
console.log("ExpensiveList rendered");
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<button onClick={() => onSelect(item.id)}>{item.label}</button>
</li>
))}
</ul>
);
});
function SearchPage() {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const items = [
{ id: 1, label: "React hooks" },
{ id: 2, label: "TypeScript" },
{ id: 3, label: "Performance" },
];
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Selected: {selectedId ?? "none"}</p>
<ExpensiveList items={items} onSelect={handleSelect} />
</div>
);
}
Expected output: "ExpensiveList rendered" appears only once on initial mount. Typing in the search input does NOT re-render ExpensiveList because handleSelect maintains a stable reference via useCallback and the component is wrapped in memo.
useRef — Mutable References and DOM Access
useRef persists mutable values across renders without causing re-renders. It also provides direct access to DOM elements.
import { useRef, useEffect, useState } from "react";
function Stopwatch() {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
function start() {
startTimeRef.current = Date.now() - elapsed;
intervalRef.current = setInterval(() => {
setElapsed(Date.now() - startTimeRef.current);
}, 10);
}
function stop() {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
function reset() {
stop();
setElapsed(0);
}
useEffect(() => {
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return (
<div>
<p>{(elapsed / 1000).toFixed(2)}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Expected output: The stopwatch displays elapsed time in seconds with two decimal places. Start begins the timer, Stop pauses it without resetting, and Reset clears both the display and internal reference. The intervalRef stores the interval ID without causing re-renders when it changes.
Common Pitfalls
1. Missing Dependencies in useEffect
Omitting a dependency that is used inside useEffect causes stale closures and bugs. The React exhaustive-deps ESLint rule catches these.
2. Over-using useMemo and useCallback
Not every computation needs memoization. The overhead of useMemo can exceed the cost of the computation for simple operations. Profile before optimizing.
3. Reading useRef in JSX
Changing ref.current does not trigger re-renders. If the UI must update when the ref value changes, store the value in state instead.
4. Infinite Loops with useEffect
Updating state inside useEffect without specifying dependencies or with a dependency that changes every render causes infinite loops.
Practice Questions
Challenge
Build a usePrevious custom hook that tracks the previous value of a state or prop. It should use useRef internally and return undefined on the first render. Write a component that displays both the current and previous search query.
Real-World Task
Optimize a product listing page that renders 500 items. Add useMemo to filter and sort the product list based on user input. Wrap the item component in React.memo and stabilize the onAddToCart callback with useCallback. Measure the render count with useRef to confirm the optimization works.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production React applications where hooks manage real-time data, performance-critical rendering, and secure DOM access.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro