Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive
2.8.e — Practical Example: Fetching API Data on Component Mount
In one sentence: This practical build takes everything from the useEffect deep dive and combines it into a complete, production-quality GitHub user search application with debouncing, caching, error handling, and accessibility.
Navigation: ← Data Fetching Pattern · Next → Topic 2.9
Table of Contents
- Project Overview
- Architecture Diagram
- Custom Hooks Layer
- The useFetch Hook
- The useDebouncedValue Hook
- The useLocalStorageState Hook
- Building the Search Input Component
- Building the User Card Component
- Building the User Detail Panel
- Building the Search Results Grid
- Assembling the App
- Adding Keyboard Navigation
- Adding Error Boundary
- Performance Optimisations
- Key Takeaways
1. Project Overview
We're building a GitHub User Search application that demonstrates every useEffect pattern from this topic:
Features
✅ Debounced search (waits for user to stop typing)
✅ Loading states with skeleton UI
✅ Error handling with retry
✅ Race condition prevention (AbortController)
✅ Simple in-memory cache
✅ Search history (localStorage)
✅ User detail panel (separate fetch)
✅ Keyboard navigation (arrow keys + Enter)
✅ Responsive grid layout
✅ Accessibility (ARIA labels, focus management)
API Used
GitHub REST API (no auth needed for basic searches):
Search: GET https://api.github.com/search/users?q={query}&per_page={count}
Detail: GET https://api.github.com/users/{username}
Rate limit: 10 requests/minute for unauthenticated users
2. Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ App │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ SearchInput │ │
│ │ - Controlled input │ │
│ │ - Debounced value (useDebouncedValue) │ │
│ │ - Search history dropdown │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌────────────────────────────┐ │
│ │ SearchResultsGrid │ │ UserDetailPanel │ │
│ │ ┌────┐ ┌────┐ │ │ - Fetches full user data │ │
│ │ │Card│ │Card│ ... │ │ - Repos, followers, bio │ │
│ │ └────┘ └────┘ │ │ - Loading skeleton │ │
│ │ ┌────┐ ┌────┐ │ │ - Error state │ │
│ │ │Card│ │Card│ ... │ └────────────────────────────┘ │
│ │ └────┘ └────┘ │ │
│ └──────────────────────┘ │
│ │
│ Custom Hooks Layer: │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ useFetch │ │useDebouncedV │ │useLocalStorageState │ │
│ └──────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
3. Custom Hooks Layer
We'll build three custom hooks that encapsulate all the useEffect patterns:
useFetch → AbortController, loading/error states, race conditions
useDebouncedValue → Timer cleanup, dependency tracking
useLocalStorageState → Sync with browser API, cleanup
4. The useFetch Hook
// hooks/useFetch.js
import { useState, useEffect, useRef, useCallback } from 'react';
// Simple in-memory cache
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function useFetch(url, options = {}) {
const {
enabled = true,
cacheKey = url,
cacheTTL = CACHE_TTL,
} = options;
const [state, setState] = useState(() => {
// Check cache on initial render
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < cacheTTL) {
return { status: 'success', data: cached.data, error: null };
}
}
return { status: enabled ? 'loading' : 'idle', data: null, error: null };
});
// Track the latest URL to prevent race conditions
const activeUrlRef = useRef(url);
const refetch = useCallback(() => {
if (!url) return;
setState({ status: 'loading', data: null, error: null });
return fetch(url)
.then(res => {
if (!res.ok) {
if (res.status === 403) throw new Error('Rate limit exceeded. Try again later.');
if (res.status === 404) throw new Error('Not found.');
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
})
.then(data => {
if (cacheKey) {
cache.set(cacheKey, { data, timestamp: Date.now() });
}
setState({ status: 'success', data, error: null });
return data;
})
.catch(err => {
setState({ status: 'error', data: null, error: err.message });
throw err;
});
}, [url, cacheKey]);
useEffect(() => {
if (!enabled || !url) {
setState({ status: 'idle', data: null, error: null });
return;
}
activeUrlRef.current = url;
// Check cache
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < cacheTTL) {
setState({ status: 'success', data: cached.data, error: null });
return;
}
}
const controller = new AbortController();
setState(prev => ({
status: 'loading',
data: prev.data, // Keep stale data while loading
error: null,
}));
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) {
if (res.status === 403) throw new Error('Rate limit exceeded. Try again later.');
if (res.status === 404) throw new Error('Not found.');
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
})
.then(data => {
// Race condition guard: only update if this is still the active request
if (activeUrlRef.current === url) {
if (cacheKey) {
cache.set(cacheKey, { data, timestamp: Date.now() });
}
setState({ status: 'success', data, error: null });
}
})
.catch(err => {
if (err.name === 'AbortError') return;
if (activeUrlRef.current === url) {
setState({ status: 'error', data: null, error: err.message });
}
});
return () => controller.abort();
}, [url, enabled, cacheKey, cacheTTL]);
return {
data: state.data,
error: state.error,
isIdle: state.status === 'idle',
isLoading: state.status === 'loading',
isSuccess: state.status === 'success',
isError: state.status === 'error',
refetch,
};
}
5. The useDebouncedValue Hook
// hooks/useDebouncedValue.js
import { useState, useEffect } from 'react';
export function useDebouncedValue(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set a timer to update debounced value after delay
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cleanup: cancel the timer if value changes before delay expires
return () => clearTimeout(timeoutId);
// How this works:
// User types "r" → timer starts (300ms)
// User types "re" (100ms later) → cleanup cancels "r" timer, new timer starts
// User types "rea" (100ms later) → cleanup cancels "re" timer, new timer starts
// User types "reac" (100ms later) → cleanup cancels "rea" timer, new timer starts
// User types "react" (100ms later) → cleanup cancels "reac" timer, new timer starts
// 300ms passes... → "react" is set as debounced value
// Only ONE API call for "react", not 5 for each keystroke!
}, [value, delay]);
return debouncedValue;
}
Visual Timeline
User types: r → re → rea → reac → react → (stops)
│ │ │ │ │
Timer: ├─x ├─x ├─x ├─x ├──── 300ms ────▶ "react"
│ │ │ │ │ │
└────┘ │ │ │ │
cancel └──────┘ │ │
cancel └────────────────────┘
Set debouncedValue
Result: API called ONCE with "react", not 5 times
6. The useLocalStorageState Hook
// hooks/useLocalStorageState.js
import { useState, useEffect } from 'react';
export function useLocalStorageState(key, initialValue) {
// Lazy initialiser: read from localStorage on first render
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.warn(`Failed to save to localStorage (key: ${key}):`, err);
}
}, [key, value]);
// Listen for changes from other tabs
useEffect(() => {
function handleStorageChange(event) {
if (event.key === key && event.newValue !== null) {
try {
setValue(JSON.parse(event.newValue));
} catch {
// Ignore parse errors
}
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [value, setValue];
}
7. Building the Search Input Component
// components/SearchInput.jsx
import { useState, useRef, useEffect } from 'react';
export function SearchInput({ value, onChange, history, onSelectHistory, onClearHistory }) {
const [showHistory, setShowHistory] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1);
const inputRef = useRef(null);
const containerRef = useRef(null);
// Close history dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowHistory(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
function handleKeyDown(event) {
if (!showHistory || history.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setHistoryIndex(i => Math.min(i + 1, history.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
setHistoryIndex(i => Math.max(i - 1, -1));
break;
case 'Enter':
if (historyIndex >= 0) {
event.preventDefault();
onSelectHistory(history[historyIndex]);
setShowHistory(false);
setHistoryIndex(-1);
}
break;
case 'Escape':
setShowHistory(false);
setHistoryIndex(-1);
break;
}
}
return (
<div ref={containerRef} style={{ position: 'relative', width: '100%', maxWidth: 600 }}>
<div style={{ display: 'flex', alignItems: 'center', border: '2px solid #e2e8f0', borderRadius: 8, padding: '8px 12px', background: '#fff' }}>
<span style={{ marginRight: 8, color: '#94a3b8' }}>🔍</span>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => history.length > 0 && setShowHistory(true)}
onKeyDown={handleKeyDown}
placeholder="Search GitHub users..."
aria-label="Search GitHub users"
aria-expanded={showHistory}
aria-autocomplete="list"
style={{ border: 'none', outline: 'none', width: '100%', fontSize: 16 }}
/>
{value && (
<button
onClick={() => { onChange(''); inputRef.current?.focus(); }}
aria-label="Clear search"
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#94a3b8' }}
>
✕
</button>
)}
</div>
{/* History Dropdown */}
{showHistory && history.length > 0 && (
<ul
role="listbox"
style={{
position: 'absolute', top: '100%', left: 0, right: 0,
background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 8, marginTop: 4, padding: 4,
listStyle: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 10,
}}
>
<li style={{ padding: '4px 8px', fontSize: 12, color: '#94a3b8', display: 'flex', justifyContent: 'space-between' }}>
<span>Recent searches</span>
<button
onClick={onClearHistory}
style={{ border: 'none', background: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 12 }}
>
Clear
</button>
</li>
{history.map((item, index) => (
<li
key={item}
role="option"
aria-selected={index === historyIndex}
onClick={() => { onSelectHistory(item); setShowHistory(false); }}
style={{
padding: '8px 12px', cursor: 'pointer', borderRadius: 4,
background: index === historyIndex ? '#f1f5f9' : 'transparent',
}}
>
🕐 {item}
</li>
))}
</ul>
)}
</div>
);
}
8. Building the User Card Component
// components/UserCard.jsx
import { memo } from 'react';
export const UserCard = memo(function UserCard({ user, isSelected, onSelect }) {
return (
<button
onClick={() => onSelect(user.login)}
aria-pressed={isSelected}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 8, border: isSelected ? '2px solid #3b82f6' : '1px solid #e2e8f0',
background: isSelected ? '#eff6ff' : '#fff', width: '100%',
cursor: 'pointer', textAlign: 'left',
transition: 'all 0.15s ease',
}}
>
<img
src={user.avatar_url}
alt={`${user.login}'s avatar`}
style={{ width: 48, height: 48, borderRadius: '50%' }}
loading="lazy"
/>
<div>
<div style={{ fontWeight: 600 }}>{user.login}</div>
<div style={{ fontSize: 13, color: '#64748b' }}>
Score: {Math.round(user.score)}
</div>
</div>
</button>
);
});
// Skeleton loading version
export function UserCardSkeleton() {
return (
<div
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 8, border: '1px solid #e2e8f0',
}}
>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: '#e2e8f0', animation: 'pulse 1.5s infinite' }} />
<div>
<div style={{ width: 120, height: 16, background: '#e2e8f0', borderRadius: 4, marginBottom: 4, animation: 'pulse 1.5s infinite' }} />
<div style={{ width: 60, height: 12, background: '#e2e8f0', borderRadius: 4, animation: 'pulse 1.5s infinite' }} />
</div>
</div>
);
}
9. Building the User Detail Panel
// components/UserDetailPanel.jsx
import { useFetch } from '../hooks/useFetch';
export function UserDetailPanel({ username, onClose }) {
// This is a SEPARATE fetch from the search — demonstrates
// multiple useEffect-based fetches coexisting
const { data: user, isLoading, isError, error, refetch } = useFetch(
username ? `https://api.github.com/users/${username}` : null,
{ enabled: !!username, cacheKey: `user-detail-${username}` }
);
if (!username) return null;
return (
<div
role="complementary"
aria-label="User details"
style={{
border: '1px solid #e2e8f0', borderRadius: 12,
padding: 24, background: '#fff', minWidth: 300,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>User Details</h2>
<button
onClick={onClose}
aria-label="Close details"
style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 18 }}
>
✕
</button>
</div>
{isLoading && <DetailSkeleton />}
{isError && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ color: '#ef4444' }}>{error}</p>
<button
onClick={refetch}
style={{
padding: '8px 16px', borderRadius: 6,
border: '1px solid #3b82f6', color: '#3b82f6',
background: 'none', cursor: 'pointer',
}}
>
Retry
</button>
</div>
)}
{user && (
<div>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<img
src={user.avatar_url}
alt={`${user.login}'s avatar`}
style={{ width: 96, height: 96, borderRadius: '50%' }}
/>
<h3 style={{ margin: '8px 0 0' }}>{user.name || user.login}</h3>
{user.bio && <p style={{ color: '#64748b', fontSize: 14 }}>{user.bio}</p>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, textAlign: 'center', marginBottom: 16 }}>
<StatBox label="Repos" value={user.public_repos} />
<StatBox label="Followers" value={user.followers} />
<StatBox label="Following" value={user.following} />
</div>
<div style={{ fontSize: 14, color: '#64748b' }}>
{user.location && <div>📍 {user.location}</div>}
{user.company && <div>🏢 {user.company}</div>}
{user.blog && (
<div>
🔗 <a href={user.blog.startsWith('http') ? user.blog : `https://${user.blog}`} target="_blank" rel="noopener noreferrer">{user.blog}</a>
</div>
)}
<div>📅 Joined {new Date(user.created_at).toLocaleDateString()}</div>
</div>
<a
href={user.html_url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block', textAlign: 'center', marginTop: 16,
padding: '10px 20px', borderRadius: 6,
background: '#24292e', color: '#fff', textDecoration: 'none',
}}
>
View on GitHub
</a>
</div>
)}
</div>
);
}
function StatBox({ label, value }) {
return (
<div style={{ padding: 8, background: '#f8fafc', borderRadius: 6 }}>
<div style={{ fontWeight: 700, fontSize: 18 }}>{value.toLocaleString()}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{label}</div>
</div>
);
}
function DetailSkeleton() {
const shimmer = { background: '#e2e8f0', borderRadius: 4, animation: 'pulse 1.5s infinite' };
return (
<div style={{ textAlign: 'center' }}>
<div style={{ ...shimmer, width: 96, height: 96, borderRadius: '50%', margin: '0 auto 12px' }} />
<div style={{ ...shimmer, width: 150, height: 20, margin: '0 auto 8px' }} />
<div style={{ ...shimmer, width: 200, height: 14, margin: '0 auto 16px' }} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{[1, 2, 3].map(i => <div key={i} style={{ ...shimmer, height: 60 }} />)}
</div>
</div>
);
}
10. Building the Search Results Grid
// components/SearchResultsGrid.jsx
import { UserCard, UserCardSkeleton } from './UserCard';
export function SearchResultsGrid({
users,
isLoading,
isError,
error,
selectedUser,
onSelectUser,
searchQuery,
totalCount,
onRetry,
}) {
// Loading state
if (isLoading && !users) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: 12 }}>
{Array.from({ length: 8 }, (_, i) => <UserCardSkeleton key={i} />)}
</div>
);
}
// Error state
if (isError) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>⚠️</div>
<h3>Something went wrong</h3>
<p style={{ color: '#64748b' }}>{error}</p>
<button
onClick={onRetry}
style={{
padding: '10px 20px', borderRadius: 6,
border: 'none', background: '#3b82f6', color: '#fff',
cursor: 'pointer', fontSize: 14,
}}
>
Try Again
</button>
</div>
);
}
// Empty state (searched but no results)
if (users && users.length === 0 && searchQuery) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>🔍</div>
<h3>No users found</h3>
<p style={{ color: '#64748b' }}>
No GitHub users match "{searchQuery}". Try a different search.
</p>
</div>
);
}
// Initial state (no search yet)
if (!users || !searchQuery) {
return (
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>👤</div>
<h3>Search GitHub Users</h3>
<p>Type a username to start searching</p>
</div>
);
}
// Results
return (
<div>
<p style={{ fontSize: 14, color: '#64748b', marginBottom: 12 }}>
Found {totalCount.toLocaleString()} users
{isLoading && ' (updating...)'}
</p>
<div
role="list"
aria-label="Search results"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: 12 }}
>
{users.map(user => (
<div key={user.id} role="listitem">
<UserCard
user={user}
isSelected={selectedUser === user.login}
onSelect={onSelectUser}
/>
</div>
))}
</div>
</div>
);
}
11. Assembling the App
// App.jsx
import { useState, useCallback } from 'react';
import { useFetch } from './hooks/useFetch';
import { useDebouncedValue } from './hooks/useDebouncedValue';
import { useLocalStorageState } from './hooks/useLocalStorageState';
import { SearchInput } from './components/SearchInput';
import { SearchResultsGrid } from './components/SearchResultsGrid';
import { UserDetailPanel } from './components/UserDetailPanel';
function App() {
// ─── State ────────────────────────────────────
const [query, setQuery] = useState('');
const [selectedUser, setSelectedUser] = useState(null);
const [searchHistory, setSearchHistory] = useLocalStorageState('gh-search-history', []);
// ─── Derived / Computed ───────────────────────
const debouncedQuery = useDebouncedValue(query, 400);
const searchUrl = debouncedQuery.length >= 2
? `https://api.github.com/search/users?q=${encodeURIComponent(debouncedQuery)}&per_page=30`
: null;
// ─── Data Fetching ────────────────────────────
const {
data: searchData,
isLoading: searchLoading,
isError: searchError,
error: searchErrorMessage,
refetch: retrySearch,
} = useFetch(searchUrl, {
enabled: !!searchUrl,
cacheKey: searchUrl,
});
// ─── Handlers ─────────────────────────────────
const handleQueryChange = useCallback((newQuery) => {
setQuery(newQuery);
setSelectedUser(null);
}, []);
const handleSelectUser = useCallback((username) => {
setSelectedUser(prev => prev === username ? null : username);
// Add to search history
if (query.length >= 2) {
setSearchHistory(prev => {
const filtered = prev.filter(item => item !== query);
return [query, ...filtered].slice(0, 10); // Keep last 10
});
}
}, [query, setSearchHistory]);
const handleSelectHistory = useCallback((historyItem) => {
setQuery(historyItem);
}, []);
const handleClearHistory = useCallback(() => {
setSearchHistory([]);
}, [setSearchHistory]);
// ─── Render ───────────────────────────────────
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
<header style={{ textAlign: 'center', marginBottom: 32 }}>
<h1 style={{ fontSize: 28, fontWeight: 700 }}>
GitHub User Search
</h1>
<p style={{ color: '#64748b' }}>
Search for GitHub users by username
</p>
</header>
{/* Search Input */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 24 }}>
<SearchInput
value={query}
onChange={handleQueryChange}
history={searchHistory}
onSelectHistory={handleSelectHistory}
onClearHistory={handleClearHistory}
/>
</div>
{/* Main Content */}
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* Results Grid */}
<div style={{ flex: 1 }}>
<SearchResultsGrid
users={searchData?.items}
isLoading={searchLoading}
isError={searchError}
error={searchErrorMessage}
selectedUser={selectedUser}
onSelectUser={handleSelectUser}
searchQuery={debouncedQuery}
totalCount={searchData?.total_count ?? 0}
onRetry={retrySearch}
/>
</div>
{/* Detail Panel */}
{selectedUser && (
<UserDetailPanel
username={selectedUser}
onClose={() => setSelectedUser(null)}
/>
)}
</div>
</div>
);
}
export default App;
12. Adding Keyboard Navigation
// hooks/useKeyboardNavigation.js
import { useEffect, useCallback } from 'react';
export function useKeyboardNavigation({ items, selectedIndex, onSelect, onNavigate, enabled = true }) {
const handleKeyDown = useCallback((event) => {
if (!enabled || items.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
onNavigate(Math.min(selectedIndex + 1, items.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
onNavigate(Math.max(selectedIndex - 1, 0));
break;
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < items.length) {
event.preventDefault();
onSelect(items[selectedIndex]);
}
break;
case 'Escape':
onNavigate(-1);
break;
}
}, [items, selectedIndex, onSelect, onNavigate, enabled]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
13. Adding Error Boundary
// components/ErrorBoundary.jsx
import { Component } from 'react';
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{ textAlign: 'center', padding: 40 }}>
<h2>Something went wrong</h2>
<p style={{ color: '#64748b' }}>{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
padding: '10px 20px', borderRadius: 6,
border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer',
}}
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage in main.jsx
// <ErrorBoundary>
// <App />
// </ErrorBoundary>
14. Performance Optimisations
What We Already Did
✅ React.memo on UserCard (prevents re-render when props unchanged)
✅ useCallback on handlers (stable references for memoized children)
✅ In-memory cache in useFetch (prevents re-fetching same URL)
✅ Debounced search (prevents excessive API calls)
✅ loading="lazy" on images (browser-native lazy loading)
✅ AbortController (cancels stale requests — saves bandwidth)
Additional Optimisations
// 1. Memoize expensive search results transformation
const sortedUsers = useMemo(() => {
if (!searchData?.items) return [];
return [...searchData.items].sort((a, b) => b.score - a.score);
}, [searchData]);
// 2. Virtualise long lists (for 100+ results)
// Use @tanstack/react-virtual for windowed rendering
// 3. Prefetch user details on hover
function UserCard({ user, onSelect, onPrefetch }) {
return (
<button
onClick={() => onSelect(user.login)}
onMouseEnter={() => onPrefetch(user.login)} // Prefetch on hover
>
{/* ... */}
</button>
);
}
// In App:
const handlePrefetch = useCallback((username) => {
const url = `https://api.github.com/users/${username}`;
if (!cache.has(`user-detail-${username}`)) {
fetch(url).then(r => r.json()).then(data => {
cache.set(`user-detail-${username}`, { data, timestamp: Date.now() });
});
}
}, []);
15. Key Takeaways
-
Custom hooks are composable. useFetch + useDebouncedValue + useLocalStorageState = a complete search app.
-
Each hook encapsulates one useEffect pattern. Cleanup, caching, debouncing — each is a separate, reusable concern.
-
Race conditions are handled at the hook level. Once useFetch handles AbortController, every component that uses it is protected.
-
State management stays simple. useState for local UI, hooks for external systems. No global state library needed for this app.
-
Skeleton loading > spinner. Skeletons reduce perceived loading time by showing the shape of content before it arrives.
-
Progressive enhancement works. Start with basic search, then add debouncing, caching, history, keyboard nav — each is an independent layer.
-
Accessibility from the start. ARIA labels, keyboard navigation, and focus management should be built in, not bolted on.
-
This is why TanStack Query exists. After building useFetch, caching, and error handling from scratch, you appreciate what the library gives you for free.
Explain-It Challenge
-
Architecture Review: If a colleague asked you to explain the architecture of this app, how would you describe the separation between hooks (data layer), components (UI layer), and the App (orchestration layer)?
-
Upgrade Plan: The PM wants real-time search results (as the user types, no debounce). What would you change? What problems would this create? How would you solve them?
-
Testing Strategy: Outline how you would test this application. Which hooks would you unit-test? Which component interactions would you integration-test? What would you mock?
Navigation: ← Data Fetching Pattern · Next → Topic 2.9