Episode 2 — React Frontend Architecture NextJS / 2.4 — React Lifecycle Methods
2.4.d — Data Fetching, Cleanup & DOM Manipulation
In one sentence: This sub-topic covers the practical side of effects — fetching data with proper error/loading states, cancelling requests with AbortController, manipulating the DOM with refs, integrating browser observers, and knowing when not to reach for useEffect.
Navigation: ← useEffect Hook · Next → Exercise Questions
Table of Contents
- Data Fetching Patterns
- Loading, Error & Data States
- AbortController — Cancelling Requests
- Race Condition Prevention
- Building a Reusable Fetch Hook
- DOM Manipulation with useRef
- Measuring DOM Elements
- Focus Management
- Scroll Management
- Integrating Third-Party DOM Libraries
- Observer APIs — IntersectionObserver, ResizeObserver, MutationObserver
- WebSocket Connection Lifecycle
- localStorage & sessionStorage Sync
- The Data Fetching Evolution
- Best Practices Checklist
- Key Takeaways
1. Data Fetching Patterns
The simplest fetch
function Posts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => setPosts(data));
}, []);
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
This works but has zero error handling, zero loading state, and a race condition bug. Let's fix everything.
Production-quality fetch
function Posts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/posts', {
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
setPosts(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
load();
return () => controller.abort();
}, []);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
if (posts.length === 0) return <EmptyState />;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Fetch with dynamic parameters
function UserPosts({ userId }) {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}/posts`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setPosts(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
load();
return () => controller.abort();
}, [userId]); // Re-fetches when userId changes
// ... render
}
When userId changes:
- Cleanup runs → AbortController aborts the old request
- New effect runs → Fresh request for new userId
- No stale data from the old user
2. Loading, Error & Data States
The state machine approach
Every fetch has exactly four possible states:
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ IDLE │ ──→ │ LOADING │ ──→ │ SUCCESS │
│ (initial) │ │ (fetching) │ │ (data!) │
└─────────────┘ └──────────────┘ └──────────────┘
│ │
↓ ↓
┌──────────────┐ ┌──────────────┐
│ ERROR │ │ REFETCH │
│ (failed) │ │ (loading) │
└──────────────┘ └──────────────┘
Pattern 1: Three separate states (most common)
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
Pattern 2: Discriminated union (more robust)
const [state, setState] = useState({ status: 'idle', data: null, error: null });
// In the effect:
setState({ status: 'loading', data: null, error: null });
// On success:
setState({ status: 'success', data: response, error: null });
// On error:
setState({ status: 'error', data: null, error: err.message });
Pattern 3: useReducer (best for complex flows)
const initialState = { data: null, loading: false, error: null };
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const controller = new AbortController();
async function load() {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
}
load();
return () => controller.abort();
}, [userId]);
const { data, loading, error } = state;
// ... render
}
Rendering different states
function DataView({ data, loading, error, onRetry }) {
if (loading) {
return (
<div className="loading" role="status" aria-label="Loading">
<Spinner />
<p>Loading...</p>
</div>
);
}
if (error) {
return (
<div className="error" role="alert">
<p>Error: {error}</p>
<button onClick={onRetry}>Retry</button>
</div>
);
}
if (!data || (Array.isArray(data) && data.length === 0)) {
return (
<div className="empty">
<p>No data found.</p>
</div>
);
}
return <div className="content">{/* render data */}</div>;
}
3. AbortController — Cancelling Requests
What is AbortController?
A browser API that lets you cancel in-flight fetch requests.
const controller = new AbortController();
// Pass the signal to fetch
fetch(url, { signal: controller.signal });
// Later, cancel the request
controller.abort();
When aborted, fetch throws an AbortError — which you should catch and ignore.
Why it matters for React
Without cancellation:
// ❌ User clicks through pages quickly:
// Page A mount → fetch('/api/a') ...pending...
// Page B mount → fetch('/api/b') ...pending...
// Page C mount → fetch('/api/c') ...pending...
// Response A arrives → setData(a) — BUT we're on page C!
// setState on unmounted component → Warning + incorrect data
With cancellation:
// ✅ With AbortController:
// Page A mount → fetch('/api/a', { signal }) ...pending...
// Page A unmount → controller.abort() → fetch A cancelled!
// Page B mount → fetch('/api/b', { signal }) ...pending...
// Page B unmount → controller.abort() → fetch B cancelled!
// Page C mount → fetch('/api/c', { signal }) → response C arrives ✅
Complete pattern
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch(url, { signal: controller.signal });
const json = await res.json();
setData(json);
} catch (err) {
if (err.name === 'AbortError') {
// Request was cancelled — component unmounted or deps changed
// Do nothing — this is expected behaviour
return;
}
// Real error — show to user
setError(err.message);
}
}
fetchData();
return () => controller.abort(); // Cancel on cleanup
}, [url]);
AbortController with multiple requests
useEffect(() => {
const controller = new AbortController();
async function loadDashboard() {
try {
const [users, stats, notifications] = await Promise.all([
fetch('/api/users', { signal: controller.signal }).then(r => r.json()),
fetch('/api/stats', { signal: controller.signal }).then(r => r.json()),
fetch('/api/notifications', { signal: controller.signal }).then(r => r.json()),
]);
setDashboard({ users, stats, notifications });
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
}
}
loadDashboard();
return () => controller.abort(); // Cancels ALL three requests
}, []);
AbortController with timeout
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
async function load() {
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
setData(data);
} catch (err) {
if (err.name === 'AbortError') {
setError('Request timed out');
} else {
setError(err.message);
}
} finally {
clearTimeout(timeoutId);
}
}
load();
return () => {
controller.abort();
clearTimeout(timeoutId);
};
}, [url]);
4. Race Condition Prevention
The problem
// User types fast: "a" → "ab" → "abc"
// Three fetches fire:
// fetch("/search?q=a") → takes 3 seconds
// fetch("/search?q=ab") → takes 1 second
// fetch("/search?q=abc") → takes 2 seconds
// Response order: "ab" (1s) → "abc" (2s) → "a" (3s)
// Final displayed result: "a" ← WRONG! Should be "abc"
Solution 1: Boolean flag (the ignore pattern)
useEffect(() => {
let ignore = false;
async function search() {
const res = await fetch(`/search?q=${query}`);
const data = await res.json();
if (!ignore) {
setResults(data); // Only set if this is still the latest effect
}
}
search();
return () => {
ignore = true; // Flag the old effect as stale
};
}, [query]);
Solution 2: AbortController (recommended — also saves bandwidth)
useEffect(() => {
const controller = new AbortController();
async function search() {
try {
const res = await fetch(`/search?q=${query}`, {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
}
}
search();
return () => controller.abort();
}, [query]);
AbortController is better because it actually cancels the network request, saving bandwidth.
How it prevents the race condition
User types "a":
Effect 1: fetch("/search?q=a") starts
User types "ab":
Cleanup 1: controller.abort() → fetch "a" CANCELLED
Effect 2: fetch("/search?q=ab") starts
User types "abc":
Cleanup 2: controller.abort() → fetch "ab" CANCELLED
Effect 3: fetch("/search?q=abc") starts
Only fetch "abc" completes → correct result displayed ✅
5. Building a Reusable Fetch Hook
Basic useFetch
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
setData(null);
setLoading(false);
return;
}
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
load();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <Profile user={user} />;
}
Advanced useFetch with options
function useFetch(url, options = {}) {
const {
method = 'GET',
body = null,
headers = {},
enabled = true, // Conditional fetching
onSuccess = null, // Callback on success
onError = null, // Callback on error
transform = (d) => d, // Transform response data
} = options;
const [state, dispatch] = useReducer(fetchReducer, {
data: null,
loading: false,
error: null,
});
const refetch = useCallback(() => {
if (!url || !enabled) return;
dispatch({ type: 'START' });
const controller = new AbortController();
fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : null,
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
const transformed = transform(data);
dispatch({ type: 'SUCCESS', payload: transformed });
onSuccess?.(transformed);
})
.catch(err => {
if (err.name !== 'AbortError') {
dispatch({ type: 'ERROR', payload: err.message });
onError?.(err);
}
});
return () => controller.abort();
}, [url, method, body, headers, enabled, transform, onSuccess, onError]);
useEffect(() => {
if (!enabled) return;
const cleanup = refetch();
return cleanup;
}, [refetch, enabled]);
return { ...state, refetch };
}
6. DOM Manipulation with useRef
What is useRef?
useRef creates a mutable container that persists across renders without causing re-renders.
const ref = useRef(initialValue);
// ref.current = initialValue
ref.current = newValue; // Mutating doesn't trigger re-render
Two uses of useRef
| Use Case | Example |
|---|---|
| DOM reference | Access actual DOM nodes |
| Mutable variable | Store values between renders |
Attaching refs to DOM elements
function TextInput() {
const inputRef = useRef(null);
const handleSubmit = () => {
// Access the actual DOM node
console.log(inputRef.current.value);
inputRef.current.focus();
inputRef.current.select();
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleSubmit}>Submit</button>
</>
);
}
Common DOM operations
function DomOps() {
const divRef = useRef(null);
useEffect(() => {
const el = divRef.current;
// Read dimensions
const { width, height } = el.getBoundingClientRect();
// Read scroll position
const scrollTop = el.scrollTop;
// Read computed styles
const bgColor = getComputedStyle(el).backgroundColor;
// Modify DOM (use sparingly — prefer state + JSX)
el.classList.add('loaded');
el.setAttribute('data-ready', 'true');
}, []);
return <div ref={divRef}>Content</div>;
}
Forwarding refs to child components
// Child component needs forwardRef to receive a ref
const CustomInput = forwardRef(function CustomInput({ label, ...props }, ref) {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
});
// Parent can now ref the input inside CustomInput
function Form() {
const nameRef = useRef(null);
const focusName = () => nameRef.current.focus();
return (
<>
<CustomInput ref={nameRef} label="Name" />
<button onClick={focusName}>Focus name</button>
</>
);
}
7. Measuring DOM Elements
Measuring on mount
function MeasuredBox() {
const ref = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
// Use useLayoutEffect to measure before paint
const { width, height } = ref.current.getBoundingClientRect();
setDimensions({ width, height });
}, []);
return (
<div ref={ref}>
<p>Width: {dimensions.width}px, Height: {dimensions.height}px</p>
</div>
);
}
Measuring when content changes
function AutoResizeTextarea({ value, onChange }) {
const textareaRef = useRef(null);
useLayoutEffect(() => {
const el = textareaRef.current;
el.style.height = 'auto'; // Reset
el.style.height = el.scrollHeight + 'px'; // Grow to content
}, [value]);
return (
<textarea
ref={textareaRef}
value={value}
onChange={onChange}
rows={1}
style={{ overflow: 'hidden', resize: 'none' }}
/>
);
}
Callback refs for dynamic elements
function DynamicList({ items }) {
const [heights, setHeights] = useState({});
// Callback ref is called whenever the element is attached/detached
const measureRef = useCallback((node) => {
if (node !== null) {
const height = node.getBoundingClientRect().height;
setHeights(prev => ({
...prev,
[node.dataset.id]: height,
}));
}
}, []);
return (
<ul>
{items.map(item => (
<li key={item.id} ref={measureRef} data-id={item.id}>
{item.content}
</li>
))}
</ul>
);
}
8. Focus Management
Auto-focus on mount
function SearchBar() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Search..." />;
}
Focus trap for modals
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocusRef.current = document.activeElement;
// Focus the modal
modalRef.current?.focus();
// Trap focus inside modal
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
const focusable = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div ref={modalRef} className="modal" role="dialog" tabIndex={-1}>
{children}
</div>
</div>
);
}
9. Scroll Management
Scroll to bottom (chat messages)
function ChatMessages({ messages }) {
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="messages">
{messages.map(msg => (
<div key={msg.id} className="message">{msg.text}</div>
))}
<div ref={endRef} />
</div>
);
}
Scroll restoration
function ScrollRestorer({ scrollKey }) {
const containerRef = useRef(null);
const scrollPositions = useRef({});
// Save scroll position when leaving
useEffect(() => {
return () => {
if (containerRef.current) {
scrollPositions.current[scrollKey] = containerRef.current.scrollTop;
}
};
}, [scrollKey]);
// Restore scroll position when arriving
useLayoutEffect(() => {
const savedPosition = scrollPositions.current[scrollKey];
if (savedPosition && containerRef.current) {
containerRef.current.scrollTop = savedPosition;
}
}, [scrollKey]);
return (
<div ref={containerRef} style={{ overflow: 'auto', height: '100vh' }}>
{/* content */}
</div>
);
}
Infinite scroll
function InfiniteList({ loadMore, hasMore }) {
const sentinelRef = useRef(null);
useEffect(() => {
if (!hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 }
);
const sentinel = sentinelRef.current;
if (sentinel) observer.observe(sentinel);
return () => {
if (sentinel) observer.unobserve(sentinel);
};
}, [loadMore, hasMore]);
return (
<>
{/* list items */}
{hasMore && <div ref={sentinelRef} className="sentinel">Loading more...</div>}
</>
);
}
10. Integrating Third-Party DOM Libraries
Chart.js integration
function ChartComponent({ data, options }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
// Create chart on mount
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
chartRef.current = new Chart(ctx, {
type: 'line',
data,
options,
});
return () => {
chartRef.current.destroy(); // Critical cleanup!
chartRef.current = null;
};
}, []); // Only create once
// Update chart when data changes
useEffect(() => {
if (chartRef.current) {
chartRef.current.data = data;
chartRef.current.update();
}
}, [data]);
// Update options separately
useEffect(() => {
if (chartRef.current) {
chartRef.current.options = options;
chartRef.current.update();
}
}, [options]);
return <canvas ref={canvasRef} />;
}
Map library integration (Mapbox/Leaflet)
function MapView({ center, zoom, markers }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
// Initialize map
useEffect(() => {
mapRef.current = new mapboxgl.Map({
container: containerRef.current,
style: 'mapbox://styles/mapbox/streets-v12',
center,
zoom,
});
return () => {
mapRef.current.remove(); // Clean up map instance
};
}, []);
// Update center/zoom
useEffect(() => {
mapRef.current?.flyTo({ center, zoom });
}, [center, zoom]);
// Update markers
useEffect(() => {
const markerInstances = markers.map(({ lng, lat, popup }) => {
return new mapboxgl.Marker()
.setLngLat([lng, lat])
.setPopup(new mapboxgl.Popup().setHTML(popup))
.addTo(mapRef.current);
});
return () => {
markerInstances.forEach(m => m.remove());
};
}, [markers]);
return <div ref={containerRef} style={{ width: '100%', height: 400 }} />;
}
Pattern: Separate creation from updates
┌──────────────────────────────────────────────────────────┐
│ useEffect(() => { │
│ instance = create(ref.current, config); │
│ return () => instance.destroy(); │
│ }, []); ← Create once │
│ │
│ useEffect(() => { │
│ instance?.updateData(data); │
│ }, [data]); ← Update on data change │
│ │
│ useEffect(() => { │
│ instance?.updateOptions(opts); │
│ }, [opts]); ← Update on options │
└──────────────────────────────────────────────────────────┘
11. Observer APIs — IntersectionObserver, ResizeObserver, MutationObserver
IntersectionObserver — Detect visibility
function useIntersection(ref, options = {}) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [ref, options.threshold, options.rootMargin]);
return isVisible;
}
// Usage: Lazy load images
function LazyImage({ src, alt }) {
const ref = useRef(null);
const isVisible = useIntersection(ref, { threshold: 0.1 });
const [loaded, setLoaded] = useState(false);
return (
<div ref={ref} className="lazy-image">
{(isVisible || loaded) && (
<img src={src} alt={alt} onLoad={() => setLoaded(true)} />
)}
</div>
);
}
ResizeObserver — Track element size
function useElementSize(ref) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return size;
}
// Usage
function ResponsiveChart({ data }) {
const containerRef = useRef(null);
const { width, height } = useElementSize(containerRef);
return (
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
<Chart data={data} width={width} height={height} />
</div>
);
}
MutationObserver — Watch DOM changes
function useMutationObserver(ref, callback, options) {
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new MutationObserver(callback);
observer.observe(element, options);
return () => observer.disconnect();
}, [ref, callback, options]);
}
// Usage: Watch for external DOM changes (e.g., third-party widget)
function WidgetContainer() {
const ref = useRef(null);
useMutationObserver(
ref,
(mutations) => {
for (const mutation of mutations) {
console.log('DOM changed:', mutation.type);
}
},
{ childList: true, subtree: true, attributes: true }
);
return <div ref={ref} id="third-party-widget" />;
}
Observer cleanup pattern
All three observers share the same cleanup pattern:
useEffect(() => {
const observer = new [Intersection|Resize|Mutation]Observer(callback, options);
observer.observe(element);
return () => observer.disconnect(); // Always disconnect!
}, [deps]);
12. WebSocket Connection Lifecycle
function useWebSocket(url, onMessage) {
const [status, setStatus] = useState('disconnected');
const wsRef = useRef(null);
const reconnectTimerRef = useRef(null);
const attemptsRef = useRef(0);
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
attemptsRef.current = 0;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
onMessage(data);
};
ws.onclose = (event) => {
setStatus('disconnected');
wsRef.current = null;
// Auto-reconnect with exponential backoff
if (!event.wasClean && attemptsRef.current < 5) {
const delay = Math.min(1000 * Math.pow(2, attemptsRef.current), 30000);
attemptsRef.current++;
reconnectTimerRef.current = setTimeout(connect, delay);
}
};
ws.onerror = () => {
setStatus('error');
};
}, [url, onMessage]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimerRef.current);
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent reconnect on intentional close
wsRef.current.close();
}
};
}, [connect]);
const send = useCallback((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
return { status, send };
}
// Usage
function LiveChat({ roomId }) {
const [messages, setMessages] = useState([]);
const handleMessage = useCallback((data) => {
setMessages(prev => [...prev, data]);
}, []);
const { status, send } = useWebSocket(
`wss://api.example.com/chat/${roomId}`,
handleMessage
);
return (
<div>
<span>Status: {status}</span>
<MessageList messages={messages} />
<MessageInput onSend={(text) => send({ type: 'message', text })} />
</div>
);
}
13. localStorage & sessionStorage Sync
Basic useLocalStorage hook
function useLocalStorage(key, initialValue) {
// Initialize from storage or fallback
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
// Update storage when value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch (err) {
console.warn('Failed to save to localStorage:', err);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
Cross-tab synchronisation
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
// Save to storage
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
// Listen for changes from OTHER tabs
useEffect(() => {
const handler = (event) => {
if (event.key === key && event.newValue !== null) {
try {
setValue(JSON.parse(event.newValue));
} catch {
setValue(event.newValue);
}
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key]);
return [value, setValue];
}
14. The Data Fetching Evolution
React's approach to data fetching has evolved significantly:
┌──────────────────────────────────────────────────────────────────┐
│ EVOLUTION OF DATA FETCHING IN REACT │
│ │
│ 2015 componentDidMount + fetch │
│ └── Manual loading/error/data states │
│ └── Race conditions, no caching │
│ │
│ 2019 useEffect + fetch │
│ └── Same problems, cleaner syntax │
│ └── AbortController for cancellation │
│ │
│ 2020 React Query (TanStack Query) / SWR │
│ └── Automatic caching, refetching, deduplication │
│ └── Loading/error states built-in │
│ └── Background refetching, optimistic updates │
│ │
│ 2023 React Server Components │
│ └── Fetch on the server — no client-side loading states │
│ └── Data available before component renders │
│ └── No useEffect needed for initial data │
│ │
│ 2024+ use() hook (React 19) │
│ └── Read promises during render │
│ └── Works with Suspense for loading states │
└──────────────────────────────────────────────────────────────────┘
When to use what
| Approach | When |
|---|---|
useEffect + fetch | Simple apps, learning, one-off fetches |
| TanStack Query / SWR | Production apps with client-side data |
| Server Components | Next.js apps, data available at build/request time |
use() + Suspense | Future standard for React 19+ apps |
Why useEffect for fetching is problematic
// Problems with useEffect + fetch:
// 1. No caching — refetch on every mount
// 2. No deduplication — 5 components fetching same URL = 5 requests
// 3. Manual loading/error management — boilerplate in every component
// 4. No background refetching — stale data after tab switch
// 5. Waterfall problem — parent fetches, then child fetches
// 6. No prefetching — user has to wait after navigation
TanStack Query comparison
// ❌ useEffect approach (30+ lines of boilerplate)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => { if (!res.ok) throw new Error(); return res.json(); })
.then(data => { setUser(data); setLoading(false); })
.catch(err => { if (err.name !== 'AbortError') { setError(err); setLoading(false); } });
return () => controller.abort();
}, [userId]);
// ...
}
// ✅ TanStack Query (3 lines)
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
// Automatic: caching, refetching, deduplication, retry, background refresh
}
Learn useEffect + fetch first to understand what's happening under the hood. Then use TanStack Query/SWR in production.
15. Best Practices Checklist
Data Fetching
- Always handle loading, error, and empty states
- Use AbortController to cancel requests on cleanup
- Prevent race conditions with abort or ignore flags
- Don't fetch in useEffect for production apps — use TanStack Query / SWR
- Handle non-OK HTTP responses (check
res.ok) - Use
try/catch/finallyfor clean error handling
DOM Manipulation
- Use
useReffor DOM access, neverdocument.querySelector - Use
useLayoutEffectfor DOM measurements that affect layout - Use
forwardRefwhen a child needs to expose its DOM node - Clean up third-party library instances (
.destroy(),.remove())
Cleanup
- Every
addEventListener→removeEventListener - Every
setInterval→clearInterval - Every
setTimeout→clearTimeout - Every
observer.observe()→observer.disconnect() - Every
WebSocketopen →.close() - Every
fetch→AbortController.abort()
General Effect Hygiene
- Don't use useEffect for derived state — compute during render
- Don't use useEffect for event responses — handle in event handlers
- Separate unrelated effects into multiple
useEffectcalls - Always include all dependencies (trust the ESLint rule)
- Use updater functions (
setX(prev => ...)) to avoid dependency on current state - Test with React Strict Mode to catch missing cleanups
16. Key Takeaways
-
Production-quality fetching requires: loading state, error state, AbortController cancellation, race condition prevention, and proper HTTP error handling.
-
AbortController is essential — it cancels in-flight requests on cleanup, preventing setState on unmounted components and saving bandwidth.
-
Race conditions happen when slow responses arrive after fast ones. Prevent with
AbortController.abort()or anignoreboolean flag. -
useRef is the bridge to the DOM — use it for measurements, focus, scroll, and third-party library containers.
-
useLayoutEffect runs before paint — use it for DOM measurements that affect visual layout to prevent flicker.
-
Observer APIs (Intersection, Resize, Mutation) all follow the same pattern: create, observe, disconnect on cleanup.
-
WebSocket connections need proper lifecycle: connect on mount, auto-reconnect with exponential backoff, close on unmount.
-
localStorage sync can include cross-tab synchronisation via the
storageevent listener. -
Data fetching has evolved: useEffect → TanStack Query → Server Components. Learn useEffect first, use libraries in production.
-
The cleanup checklist is your safety net — every setup needs a corresponding teardown.
Explain-It Challenge
-
The Walkie-Talkie: AbortController is like a walkie-talkie with a "cancel" button. When you send a message (fetch request) and then realise you need to change the channel (userId changes), you press "cancel" on the old walkie-talkie before starting a new one. What happens if you don't cancel — does the old message disappear or does it arrive at the wrong destination?
-
The Hotel Room Service: Race conditions are like ordering room service in three different hotels in quick succession. Hotel 1 (slow kitchen) takes 3 hours, Hotel 2 takes 1 hour, Hotel 3 takes 2 hours. If you only want the food from Hotel 3 (the last one you ordered), how does AbortController ensure Hotels 1 and 2 stop cooking (saving resources), while the
ignoreflag merely tells you to throw their food away when it arrives? -
The Smart Mirror: IntersectionObserver is like a smart mirror that only activates when you step in front of it. The mirror doesn't constantly check "is someone there?" — it uses motion sensors to trigger only when visibility changes. How does this differ from a
setIntervalthat checksgetBoundingClientRect()every 100ms, and why is the Observer approach better for performance?
Navigation: ← useEffect Hook · Next → Exercise Questions