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

  1. Project Overview
  2. Architecture Diagram
  3. Custom Hooks Layer
  4. The useFetch Hook
  5. The useDebouncedValue Hook
  6. The useLocalStorageState Hook
  7. Building the Search Input Component
  8. Building the User Card Component
  9. Building the User Detail Panel
  10. Building the Search Results Grid
  11. Assembling the App
  12. Adding Keyboard Navigation
  13. Adding Error Boundary
  14. Performance Optimisations
  15. 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

  1. Custom hooks are composable. useFetch + useDebouncedValue + useLocalStorageState = a complete search app.

  2. Each hook encapsulates one useEffect pattern. Cleanup, caching, debouncing — each is a separate, reusable concern.

  3. Race conditions are handled at the hook level. Once useFetch handles AbortController, every component that uses it is protected.

  4. State management stays simple. useState for local UI, hooks for external systems. No global state library needed for this app.

  5. Skeleton loading > spinner. Skeletons reduce perceived loading time by showing the shape of content before it arrives.

  6. Progressive enhancement works. Start with basic search, then add debouncing, caching, history, keyboard nav — each is an independent layer.

  7. Accessibility from the start. ARIA labels, keyboard navigation, and focus management should be built in, not bolted on.

  8. 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

  1. 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)?

  2. 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?

  3. 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