Skip to main content

๐Ÿš€ Advanced Data Fetching

You've learned the basics of data fetching with useEffect and the Fetch API. But real-world applications face challenges like race conditions, slow API responses, and large datasets. How do you build a search that doesn't overwhelm the server with requests? How do you handle pagination elegantly? What about when users navigate away before data loads? In this lesson, we'll explore advanced data fetching patterns that make your React apps fast, efficient, and production-ready. Get ready to level up your data game! ๐ŸŽฏ

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Understand and prevent race conditions in data fetching
  • Implement debouncing and throttling for user input
  • Build paginated data displays with proper state management
  • Create infinite scroll components
  • Implement client-side caching strategies
  • Use AbortController for request cancellation
  • Handle stale data and revalidation
  • Understand React Query fundamentals
  • Type complex async operations properly
  • Build production-ready data fetching solutions

Estimated Time: 75-90 minutes

Project: Build a searchable, paginated product catalog with caching

๐Ÿ“‘ In This Lesson

๐Ÿ Race Conditions: The Silent Bug

Race conditions are one of the trickiest bugs in data fetching. They occur when multiple async operations complete out of order, causing your UI to display stale or incorrect data. Let's understand and fix them!

๐Ÿ“– Definition

Race Condition: A bug that occurs when the outcome depends on the unpredictable timing of multiple operations. In data fetching, this happens when a later request completes before an earlier one, displaying outdated data.

The Problem: Out-of-Order Responses

Imagine a user typing in a search box: "react" โ†’ "react typescript" โ†’ "react query". Three requests fire off, but the network is unpredictableโ€”the "react" request might finish last, showing old results!

โš ๏ธ Broken Code: Race Condition

interface SearchProps {
    query: string;
}

interface SearchResult {
    id: number;
    title: string;
}

const SearchResults: React.FC<SearchProps> = ({ query }) => {
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        if (!query) return;
        
        setLoading(true);
        
        // Problem: No way to know if this is still the latest query!
        fetch(`/api/search?q=${query}`)
            .then(res => res.json())
            .then(data => {
                setResults(data); // โŒ Might be stale data!
                setLoading(false);
            });
    }, [query]);

    return (
        <div>
            {loading && <p>Loading...</p>}
            {results.map(result => (
                <div key={result.id}>{result.title}</div>
            ))}
        </div>
    );
};

// Scenario:
// User types: "react" โ†’ fires request A
// User types: "react typescript" โ†’ fires request B  
// Request B finishes first โ†’ shows correct results
// Request A finishes second โ†’ overwrites with old "react" results โŒ

Solution 1: Ignore Flag

Use a boolean flag to track whether the effect is still active:

โœ… Fixed with Ignore Flag

const SearchResults: React.FC<SearchProps> = ({ query }) => {
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        if (!query) return;
        
        let ignore = false; // Flag to track if this effect is still active
        
        setLoading(true);
        
        fetch(`/api/search?q=${query}`)
            .then(res => res.json())
            .then(data => {
                if (!ignore) { // โœ… Only update if still the latest query
                    setResults(data);
                    setLoading(false);
                }
            });

        // Cleanup: Set ignore to true when effect is cleaned up
        return () => {
            ignore = true;
        };
    }, [query]);

    return (
        <div>
            {loading && <p>Loading...</p>}
            {results.map(result => (
                <div key={result.id}>{result.title}</div>
            ))}
        </div>
    );
};

// How it works:
// 1. User types "react" โ†’ request A starts, ignore = false
// 2. User types "react typescript" โ†’ effect cleanup runs, sets ignore = true for A
// 3. New request B starts with its own ignore = false
// 4. If request A finishes, it sees ignore = true and skips updating โœ…
// 5. Request B finishes, sees ignore = false, updates state โœ…

Solution 2: AbortController (Better!)

AbortController lets you actually cancel the in-flight request, not just ignore it:

โœ… Best Solution: AbortController

const SearchResults: React.FC<SearchProps> = ({ query }) => {
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        if (!query) return;
        
        // Create AbortController for this request
        const controller = new AbortController();
        
        setLoading(true);
        
        fetch(`/api/search?q=${query}`, {
            signal: controller.signal // Pass the signal to fetch
        })
            .then(res => res.json())
            .then(data => {
                setResults(data);
                setLoading(false);
            })
            .catch(err => {
                if (err.name === 'AbortError') {
                    // Request was cancelled - this is expected!
                    console.log('Request cancelled');
                } else {
                    // Real error
                    console.error('Error:', err);
                    setLoading(false);
                }
            });

        // Cleanup: Abort the request if component unmounts or query changes
        return () => {
            controller.abort();
        };
    }, [query]);

    return (
        <div>
            {loading && <p>Loading...</p>}
            {results.map(result => (
                <div key={result.id}>{result.title}</div>
            ))}
        </div>
    );
};

๐Ÿ’ก Why AbortController is Better

  • Actually cancels the request - Saves bandwidth and server resources
  • Browser stops processing - No wasted CPU parsing JSON from cancelled requests
  • Cleaner code - The intent is clear: "cancel this request"
  • Standard Web API - Works with fetch, axios (with adapters), and other libraries

Visualizing the Race Condition

sequenceDiagram participant User participant Component participant Server User->>Component: Types "react" Component->>Server: Request A: "react" Note over Server: Slow network... User->>Component: Types "react typescript" Component->>Server: Request B: "react typescript" Server->>Component: Response B arrives first โœ… Note over Component: Shows correct results Server->>Component: Response A arrives second โŒ Note over Component: Without fix: Shows old results! Note over Component: With AbortController: Request A was cancelled โœ…

๐ŸŽฎ Interactive: Race Condition Simulator

Click the buttons to see how race conditions happen and how AbortController fixes them!

Click a button to start the simulation 0ms 500ms 1500ms 2500ms User Types Request A: "react" Request B: "react ts" UI Display: (waiting...) CANCELLED

Race Conditions in Custom Hooks

Let's build a reusable hook that handles race conditions automatically:

interface UseFetchOptions {
    immediate?: boolean;
}

interface UseFetchReturn<T> {
    data: T | null;
    loading: boolean;
    error: Error | null;
    refetch: () => Promise<void>;
}

function useFetch<T>(
    url: string,
    options: UseFetchOptions = {}
): UseFetchReturn<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);

    const fetchData = useCallback(async () => {
        const controller = new AbortController();
        
        setLoading(true);
        setError(null);

        try {
            const response = await fetch(url, {
                signal: controller.signal
            });

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }

            const result = await response.json();
            setData(result);
        } catch (err) {
            if (err instanceof Error && err.name !== 'AbortError') {
                setError(err);
            }
        } finally {
            setLoading(false);
        }

        return controller;
    }, [url]);

    useEffect(() => {
        if (options.immediate ?? true) {
            const controller = fetchData();
            
            return () => {
                controller.then(c => c.abort());
            };
        }
    }, [fetchData, options.immediate]);

    const refetch = useCallback(async () => {
        await fetchData();
    }, [fetchData]);

    return { data, loading, error, refetch };
}

// Usage - race conditions handled automatically!
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const { data: user, loading, error } = useFetch<User>(
        `/api/users/${userId}`
    );

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    if (!user) return null;

    return <div>{user.name}</div>;
};

โœ… Key Takeaways: Race Conditions

  • Race conditions happen when async operations complete out of order
  • Always use AbortController to cancel old requests
  • Return cleanup functions from useEffect that abort requests
  • Build reusable hooks that handle cancellation automatically
  • Test with slow network conditions (Chrome DevTools throttling!)

โฑ๏ธ Debouncing and Throttling

When users type in a search box, do you really want to make an API call for every single keystroke? That's a lot of unnecessary requests! Debouncing and throttling are techniques to limit how often a function runs. Let's master them!

๐Ÿ“– Definitions

Debouncing: Delays executing a function until after a certain time has passed since the last call. Perfect for search inputsโ€”wait until the user stops typing.

Throttling: Limits how often a function can execute (e.g., at most once every 500ms). Great for scroll handlers or window resize events.

The Problem: Too Many Requests

โš ๏ธ Without Debouncing

const Search: React.FC = () => {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState<string[]>([]);

    useEffect(() => {
        if (!query) return;
        
        // โŒ Fires on EVERY keystroke!
        fetch(`/api/search?q=${query}`)
            .then(res => res.json())
            .then(setResults);
    }, [query]);

    return (
        <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search..."
        />
    );
};

// User types: "r e a c t"
// Fires 5 requests: "r", "re", "rea", "reac", "react"
// Only the last one matters! โŒ

Solution: Debouncing

Let's build a custom useDebounce hook:

โœ… useDebounce Hook

function useDebounce<T>(value: T, delay: number): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);

    useEffect(() => {
        // Set up a timer to update the debounced value
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // Clean up the timer if value changes before delay expires
        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]); // Re-run effect when value or delay changes

    return debouncedValue;
}

// Usage
const Search: React.FC = () => {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 500); // Wait 500ms after typing stops
    const [results, setResults] = useState<string[]>([]);

    useEffect(() => {
        if (!debouncedQuery) return;
        
        const controller = new AbortController();
        
        // โœ… Only fires after user stops typing for 500ms!
        fetch(`/api/search?q=${debouncedQuery}`, {
            signal: controller.signal
        })
            .then(res => res.json())
            .then(setResults)
            .catch(err => {
                if (err.name !== 'AbortError') {
                    console.error(err);
                }
            });

        return () => controller.abort();
    }, [debouncedQuery]);

    return (
        <div>
            <input
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
            />
            <div>
                {results.map(result => (
                    <div key={result}>{result}</div>
                ))}
            </div>
        </div>
    );
};

// User types: "r e a c t"
// Only fires 1 request: "react" (after 500ms pause) โœ…
// Much better!

How Debouncing Works

sequenceDiagram participant User participant Input participant Timer participant API User->>Input: Types "r" Input->>Timer: Start 500ms timer Note over Timer: Waiting... User->>Input: Types "e" (100ms later) Input->>Timer: Cancel old timer, start new 500ms timer Note over Timer: Waiting... User->>Input: Types "a" (100ms later) Input->>Timer: Cancel old timer, start new 500ms timer Note over Timer: Waiting... User->>Input: Types "c" (100ms later) Input->>Timer: Cancel old timer, start new 500ms timer Note over Timer: Waiting... User->>Input: Types "t" (100ms later) Input->>Timer: Cancel old timer, start new 500ms timer Note over Timer: Waiting 500ms... Note over User: User stops typing Timer->>API: 500ms passed! Fire request "react" โœ…

๐ŸŽฎ Interactive: Debounce vs Throttle Comparison

Type in the input below and watch how debouncing and throttling handle rapid input differently!

Raw Input Events
Debounced (500ms)
Throttled (500ms)
0
Raw Events
0
Debounced
0
Throttled

Throttling for Scroll Events

Throttling is differentโ€”it guarantees a function runs at most once per time period:

function useThrottle<T>(value: T, limit: number): T {
    const [throttledValue, setThrottledValue] = useState<T>(value);
    const lastRan = useRef(Date.now());

    useEffect(() => {
        const timer = setTimeout(() => {
            if (Date.now() - lastRan.current >= limit) {
                setThrottledValue(value);
                lastRan.current = Date.now();
            }
        }, limit - (Date.now() - lastRan.current));

        return () => {
            clearTimeout(timer);
        };
    }, [value, limit]);

    return throttledValue;
}

// Usage: Infinite scroll
const InfiniteList: React.FC = () => {
    const [scrollY, setScrollY] = useState(0);
    const throttledScrollY = useThrottle(scrollY, 200); // At most every 200ms

    useEffect(() => {
        const handleScroll = () => {
            setScrollY(window.scrollY);
        };

        window.addEventListener('scroll', handleScroll);
        return () => window.removeEventListener('scroll', handleScroll);
    }, []);

    useEffect(() => {
        // Check if we need to load more data
        // This only runs at most once every 200ms โœ…
        const windowHeight = window.innerHeight;
        const documentHeight = document.documentElement.scrollHeight;
        
        if (throttledScrollY + windowHeight >= documentHeight - 100) {
            console.log('Load more data!');
            // Fetch more data...
        }
    }, [throttledScrollY]);

    return <div>Scroll down...</div>;
};

Debounce vs Throttle: When to Use Which?

๐Ÿ’ก Quick Guide

Technique When to Use Examples
Debounce Wait until user stops doing something Search input, form validation, window resize
Throttle Limit frequency of continuous events Scroll handlers, mouse move, window resize (continuous feedback)

โœ… Pro Tips

  • Debounce delays: 300-500ms is typical for search, 1000ms+ for autosave
  • Show loading indicators: Users should know their input is being processed
  • Combine with AbortController: Cancel old requests when new debounced value arrives
  • Consider lodash: Libraries like lodash provide battle-tested implementations

๐Ÿ›‘ Request Cancellation with AbortController

We've touched on AbortController for race conditions, but let's dive deeper. Properly cancelling requests is crucial for building responsive, efficient applications. Let's master this powerful API!

AbortController Deep Dive

The AbortController API consists of two parts:

// 1. The Controller - creates and controls the signal
const controller = new AbortController();

// 2. The Signal - passed to fetch, can be "aborted"
const signal = controller.signal;

// Check if aborted
console.log(signal.aborted); // false

// Listen for abort events
signal.addEventListener('abort', () => {
    console.log('Request was aborted!');
});

// Abort the request
controller.abort();

console.log(signal.aborted); // true

Pattern 1: User-Triggered Cancellation

Let users cancel long-running requests:

โœ… Cancellable Request

interface Report {
    id: string;
    data: any;
}

const ReportGenerator: React.FC = () => {
    const [loading, setLoading] = useState(false);
    const [report, setReport] = useState<Report | null>(null);
    const [error, setError] = useState<string | null>(null);
    const controllerRef = useRef<AbortController | null>(null);

    const generateReport = async () => {
        // Cancel any existing request
        if (controllerRef.current) {
            controllerRef.current.abort();
        }

        // Create new controller
        controllerRef.current = new AbortController();
        
        setLoading(true);
        setError(null);

        try {
            const response = await fetch('/api/generate-report', {
                method: 'POST',
                signal: controllerRef.current.signal,
                body: JSON.stringify({ type: 'annual' })
            });

            if (!response.ok) {
                throw new Error('Failed to generate report');
            }

            const data = await response.json();
            setReport(data);
        } catch (err) {
            if (err instanceof Error) {
                if (err.name === 'AbortError') {
                    console.log('Report generation cancelled');
                } else {
                    setError(err.message);
                }
            }
        } finally {
            setLoading(false);
            controllerRef.current = null;
        }
    };

    const cancelGeneration = () => {
        if (controllerRef.current) {
            controllerRef.current.abort();
            setLoading(false);
        }
    };

    return (
        <div>
            <button onClick={generateReport} disabled={loading}>
                Generate Report
            </button>
            
            {loading && (
                <div>
                    <p>Generating report...</p>
                    <button onClick={cancelGeneration}>Cancel</button>
                </div>
            )}
            
            {error && <p style={{ color: 'red' }}>Error: {error}</p>}
            {report && <pre>{JSON.stringify(report, null, 2)}</pre>}
        </div>
    );
};

Pattern 2: Timeout with AbortController

Implement request timeouts to prevent hanging forever:

async function fetchWithTimeout(
    url: string,
    options: RequestInit = {},
    timeout: number = 5000 // 5 second default
): Promise<Response> {
    const controller = new AbortController();
    
    // Set up timeout
    const timeoutId = setTimeout(() => {
        controller.abort();
    }, timeout);

    try {
        const response = await fetch(url, {
            ...options,
            signal: controller.signal
        });
        
        clearTimeout(timeoutId);
        return response;
    } catch (err) {
        clearTimeout(timeoutId);
        
        if (err instanceof Error && err.name === 'AbortError') {
            throw new Error(`Request timeout after ${timeout}ms`);
        }
        throw err;
    }
}

// Usage
const DataFetcher: React.FC = () => {
    const [data, setData] = useState(null);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        fetchWithTimeout('/api/slow-endpoint', {}, 3000)
            .then(res => res.json())
            .then(setData)
            .catch(err => setError(err.message));
    }, []);

    if (error) return <div>Error: {error}</div>;
    return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
};

Pattern 3: Multiple Request Cancellation

Cancel multiple related requests at once:

interface DashboardData {
    users: User[];
    posts: Post[];
    analytics: Analytics;
}

const Dashboard: React.FC = () => {
    const [data, setData] = useState<Partial<DashboardData>>({});
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        const controller = new AbortController();
        const signal = controller.signal;

        setLoading(true);

        // Fire multiple requests with the same signal
        Promise.all([
            fetch('/api/users', { signal }).then(r => r.json()),
            fetch('/api/posts', { signal }).then(r => r.json()),
            fetch('/api/analytics', { signal }).then(r => r.json())
        ])
            .then(([users, posts, analytics]) => {
                setData({ users, posts, analytics });
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    console.error('Error loading dashboard:', err);
                }
            })
            .finally(() => {
                setLoading(false);
            });

        // Cleanup: abort ALL requests when component unmounts
        return () => {
            controller.abort();
        };
    }, []);

    if (loading) return <div>Loading dashboard...</div>;

    return (
        <div>
            <h2>Dashboard</h2>
            {data.users && <UsersList users={data.users} />}
            {data.posts && <PostsList posts={data.posts} />}
            {data.analytics && <Analytics data={data.analytics} />}
        </div>
    );
};

Pattern 4: AbortController in Custom Hooks

Build a robust, reusable fetch hook with cancellation:

โœ… Production-Ready useFetch Hook

interface UseFetchOptions extends RequestInit {
    immediate?: boolean;
    timeout?: number;
}

interface UseFetchReturn<T> {
    data: T | null;
    loading: boolean;
    error: Error | null;
    refetch: () => Promise<void>;
    abort: () => void;
}

function useFetch<T>(
    url: string,
    options: UseFetchOptions = {}
): UseFetchReturn<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
    const controllerRef = useRef<AbortController | null>(null);

    const abort = useCallback(() => {
        if (controllerRef.current) {
            controllerRef.current.abort();
            controllerRef.current = null;
        }
    }, []);

    const fetchData = useCallback(async () => {
        // Abort any existing request
        abort();

        // Create new controller
        controllerRef.current = new AbortController();
        const { immediate, timeout, ...fetchOptions } = options;

        setLoading(true);
        setError(null);

        try {
            // Set up timeout if specified
            let timeoutId: NodeJS.Timeout | undefined;
            if (timeout) {
                timeoutId = setTimeout(() => {
                    controllerRef.current?.abort();
                }, timeout);
            }

            const response = await fetch(url, {
                ...fetchOptions,
                signal: controllerRef.current.signal
            });

            if (timeoutId) clearTimeout(timeoutId);

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }

            const result = await response.json();
            setData(result);
        } catch (err) {
            if (err instanceof Error && err.name !== 'AbortError') {
                setError(err);
            }
        } finally {
            setLoading(false);
        }
    }, [url, options, abort]);

    useEffect(() => {
        if (options.immediate ?? true) {
            fetchData();
        }

        // Cleanup: abort on unmount or dependency change
        return () => {
            abort();
        };
    }, [fetchData, options.immediate, abort]);

    return {
        data,
        loading,
        error,
        refetch: fetchData,
        abort
    };
}

// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const { data: user, loading, error, refetch, abort } = useFetch<User>(
        `/api/users/${userId}`,
        { timeout: 5000 }
    );

    if (loading) {
        return (
            <div>
                Loading...
                <button onClick={abort}>Cancel</button>
            </div>
        );
    }

    if (error) return <div>Error: {error.message}</div>;
    if (!user) return null;

    return (
        <div>
            <h2>{user.name}</h2>
            <button onClick={refetch}>Refresh</button>
        </div>
    );
};

๐Ÿ’ก AbortController Best Practices

  • Always clean up: Abort requests in useEffect cleanup functions
  • Store in refs: Use useRef to keep controller references across renders
  • Handle AbortError: Don't treat cancelled requests as real errors
  • Provide user feedback: Show cancel buttons for long operations
  • Timeout pattern: Combine with setTimeout for automatic timeouts
  • One signal, many requests: Use the same signal for related requests

๐Ÿ“„ Pagination Patterns

Large datasets can't be loaded all at onceโ€”pagination splits data into manageable chunks. Let's explore different pagination strategies and build a complete pagination system!

๐Ÿ“– Definition

Pagination: A technique for dividing large datasets into discrete pages, loading only the data needed for the current page. This improves performance, reduces bandwidth, and enhances user experience.

Pagination Strategies

Strategy How It Works Best For API Pattern
Offset/Limit Skip N items, take M items Static data, known total count ?page=2&limit=20 or ?offset=20&limit=20
Cursor-Based Use last item's ID as cursor Real-time data, social feeds ?cursor=abc123&limit=20
Page Numbers Request specific page number Traditional pagination UI ?page=3&per_page=20

Pattern 1: Offset-Based Pagination

The most common pagination pattern:

โœ… Complete Pagination Component

interface Product {
    id: number;
    name: string;
    price: number;
}

interface PaginationInfo {
    currentPage: number;
    totalPages: number;
    totalItems: number;
    itemsPerPage: number;
}

interface ApiResponse {
    data: Product[];
    pagination: PaginationInfo;
}

const ProductList: React.FC = () => {
    const [products, setProducts] = useState<Product[]>([]);
    const [pagination, setPagination] = useState<PaginationInfo>({
        currentPage: 1,
        totalPages: 1,
        totalItems: 0,
        itemsPerPage: 20
    });
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const fetchProducts = useCallback(async (page: number) => {
        const controller = new AbortController();
        
        setLoading(true);
        setError(null);

        try {
            const response = await fetch(
                `/api/products?page=${page}&limit=${pagination.itemsPerPage}`,
                { signal: controller.signal }
            );

            if (!response.ok) {
                throw new Error('Failed to fetch products');
            }

            const data: ApiResponse = await response.json();
            setProducts(data.data);
            setPagination(data.pagination);
        } catch (err) {
            if (err instanceof Error && err.name !== 'AbortError') {
                setError(err.message);
            }
        } finally {
            setLoading(false);
        }

        return controller;
    }, [pagination.itemsPerPage]);

    useEffect(() => {
        const controller = fetchProducts(pagination.currentPage);
        return () => {
            controller.then(c => c.abort());
        };
    }, [pagination.currentPage, fetchProducts]);

    const goToPage = (page: number) => {
        setPagination(prev => ({ ...prev, currentPage: page }));
        window.scrollTo({ top: 0, behavior: 'smooth' });
    };

    const nextPage = () => {
        if (pagination.currentPage < pagination.totalPages) {
            goToPage(pagination.currentPage + 1);
        }
    };

    const previousPage = () => {
        if (pagination.currentPage > 1) {
            goToPage(pagination.currentPage - 1);
        }
    };

    if (loading && products.length === 0) {
        return <div>Loading products...</div>;
    }

    if (error) {
        return <div>Error: {error}</div>;
    }

    return (
        <div>
            <h2>Products</h2>
            
            {/* Product Grid */}
            <div style={{ 
                display: 'grid', 
                gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
                gap: '1rem'
            }}>
                {products.map(product => (
                    <div key={product.id} style={{
                        border: '1px solid #ddd',
                        padding: '1rem',
                        borderRadius: '8px'
                    }}>
                        <h3>{product.name}</h3>
                        <p>${product.price}</p>
                    </div>
                ))}
            </div>

            {loading && <div style={{ textAlign: 'center', margin: '2rem' }}>Loading...</div>}

            {/* Pagination Controls */}
            <div style={{
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                gap: '1rem',
                marginTop: '2rem'
            }}>
                <button 
                    onClick={previousPage} 
                    disabled={pagination.currentPage === 1 || loading}
                >
                    Previous
                </button>

                <span>
                    Page {pagination.currentPage} of {pagination.totalPages}
                    <br />
                    ({pagination.totalItems} total items)
                </span>

                <button 
                    onClick={nextPage}
                    disabled={pagination.currentPage === pagination.totalPages || loading}
                >
                    Next
                </button>
            </div>

            {/* Page Numbers */}
            <div style={{
                display: 'flex',
                justifyContent: 'center',
                gap: '0.5rem',
                marginTop: '1rem'
            }}>
                {Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map(page => (
                    <button
                        key={page}
                        onClick={() => goToPage(page)}
                        disabled={loading}
                        style={{
                            padding: '0.5rem 1rem',
                            backgroundColor: page === pagination.currentPage ? '#667eea' : 'white',
                            color: page === pagination.currentPage ? 'white' : 'black',
                            border: '1px solid #667eea',
                            borderRadius: '4px',
                            cursor: 'pointer'
                        }}
                    >
                        {page}
                    </button>
                ))}
            </div>
        </div>
    );
};

Pattern 2: Reusable Pagination Hook

Extract pagination logic into a custom hook:

interface UsePaginationOptions<T> {
    fetchFunction: (page: number, limit: number) => Promise<{
        data: T[];
        pagination: PaginationInfo;
    }>;
    initialPage?: number;
    itemsPerPage?: number;
}

interface UsePaginationReturn<T> {
    data: T[];
    pagination: PaginationInfo;
    loading: boolean;
    error: Error | null;
    goToPage: (page: number) => void;
    nextPage: () => void;
    previousPage: () => void;
    refresh: () => void;
}

function usePagination<T>(
    options: UsePaginationOptions<T>
): UsePaginationReturn<T> {
    const { fetchFunction, initialPage = 1, itemsPerPage = 20 } = options;
    
    const [data, setData] = useState<T[]>([]);
    const [pagination, setPagination] = useState<PaginationInfo>({
        currentPage: initialPage,
        totalPages: 1,
        totalItems: 0,
        itemsPerPage
    });
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);

    const fetchData = useCallback(async (page: number) => {
        setLoading(true);
        setError(null);

        try {
            const result = await fetchFunction(page, itemsPerPage);
            setData(result.data);
            setPagination(result.pagination);
        } catch (err) {
            setError(err as Error);
        } finally {
            setLoading(false);
        }
    }, [fetchFunction, itemsPerPage]);

    useEffect(() => {
        fetchData(pagination.currentPage);
    }, [pagination.currentPage, fetchData]);

    const goToPage = useCallback((page: number) => {
        if (page >= 1 && page <= pagination.totalPages) {
            setPagination(prev => ({ ...prev, currentPage: page }));
            window.scrollTo({ top: 0, behavior: 'smooth' });
        }
    }, [pagination.totalPages]);

    const nextPage = useCallback(() => {
        goToPage(pagination.currentPage + 1);
    }, [pagination.currentPage, goToPage]);

    const previousPage = useCallback(() => {
        goToPage(pagination.currentPage - 1);
    }, [pagination.currentPage, goToPage]);

    const refresh = useCallback(() => {
        fetchData(pagination.currentPage);
    }, [pagination.currentPage, fetchData]);

    return {
        data,
        pagination,
        loading,
        error,
        goToPage,
        nextPage,
        previousPage,
        refresh
    };
}

// Usage - Much cleaner!
const ProductList: React.FC = () => {
    const fetchProducts = async (page: number, limit: number) => {
        const response = await fetch(`/api/products?page=${page}&limit=${limit}`);
        return response.json();
    };

    const {
        data: products,
        pagination,
        loading,
        error,
        nextPage,
        previousPage,
        goToPage
    } = usePagination({ fetchFunction: fetchProducts });

    if (error) return <div>Error: {error.message}</div>;

    return (
        <div>
            {loading && <div>Loading...</div>}
            
            <div>
                {products.map(product => (
                    <div key={product.id}>{product.name}</div>
                ))}
            </div>

            <PaginationControls
                pagination={pagination}
                onNext={nextPage}
                onPrevious={previousPage}
                onGoToPage={goToPage}
            />
        </div>
    );
};

Pattern 3: URL-Based Pagination

Sync pagination state with the URL for shareable links:

import { useSearchParams } from 'react-router-dom';

const ProductList: React.FC = () => {
    const [searchParams, setSearchParams] = useSearchParams();
    const currentPage = Number(searchParams.get('page')) || 1;
    
    const [products, setProducts] = useState<Product[]>([]);
    const [totalPages, setTotalPages] = useState(1);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        const controller = new AbortController();
        
        setLoading(true);
        
        fetch(`/api/products?page=${currentPage}&limit=20`, {
            signal: controller.signal
        })
            .then(res => res.json())
            .then(data => {
                setProducts(data.data);
                setTotalPages(data.pagination.totalPages);
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    console.error(err);
                }
            })
            .finally(() => setLoading(false));

        return () => controller.abort();
    }, [currentPage]);

    const goToPage = (page: number) => {
        setSearchParams({ page: page.toString() });
    };

    return (
        <div>
            {loading && <div>Loading...</div>}
            
            {products.map(product => (
                <div key={product.id}>{product.name}</div>
            ))}

            <div>
                <button 
                    onClick={() => goToPage(currentPage - 1)}
                    disabled={currentPage === 1}
                >
                    Previous
                </button>
                
                <span>Page {currentPage} of {totalPages}</span>
                
                <button 
                    onClick={() => goToPage(currentPage + 1)}
                    disabled={currentPage === totalPages}
                >
                    Next
                </button>
            </div>
        </div>
    );
};

// Now users can share URLs like:
// /products?page=5
// And the page state is preserved! โœ…

โœ… Pagination Best Practices

  • Show total count: Users want to know how much data exists
  • Disable during loading: Prevent double-clicks and race conditions
  • Scroll to top: When changing pages, scroll the user to the top
  • URL sync: Store page in URL for shareable, bookmarkable links
  • Loading states: Show skeleton screens or spinners during fetch
  • Smart page numbers: Don't show all 1000 pagesโ€”use ellipsis (...)
  • Cancel old requests: Always use AbortController for pagination

โ™พ๏ธ Infinite Scroll

Instead of clicking "Next Page," what if content just loaded as you scroll down? That's infinite scrollโ€”popular on social media and modern web apps. Let's build it!

๐Ÿ“– Definition

Infinite Scroll: A UX pattern where new content automatically loads as the user scrolls down the page. Instead of pagination controls, scrolling triggers data fetching, creating a seamless, continuous browsing experience.

How Infinite Scroll Works

sequenceDiagram participant User participant Component participant Scroll Detector participant API User->>Component: Views page Component->>API: Load page 1 API->>Component: Returns items 1-20 Component->>User: Displays items User->>Scroll Detector: Scrolls down Note over Scroll Detector: Near bottom detected! Scroll Detector->>Component: Trigger load more Component->>API: Load page 2 API->>Component: Returns items 21-40 Component->>User: Appends new items User->>Scroll Detector: Continues scrolling Note over Scroll Detector: Near bottom again! Scroll Detector->>Component: Trigger load more Component->>API: Load page 3 Note over API: No more data API->>Component: Returns empty array Component->>User: Shows "No more items"

๐ŸŽฎ Interactive: Infinite Scroll Demo

Scroll down in the container below to see how Intersection Observer triggers data loading!

๐Ÿ‘‡ Scroll to load more...
1
Pages Loaded
5
Total Items
Watching
Observer

Detecting When to Load More

We need to detect when the user is near the bottom of the page:

// Method 1: Scroll position calculation
function isNearBottom(threshold: number = 100): boolean {
    const windowHeight = window.innerHeight;
    const documentHeight = document.documentElement.scrollHeight;
    const scrollTop = window.scrollY;
    
    // Distance from bottom
    const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
    
    return distanceFromBottom < threshold;
}

// Method 2: Using Intersection Observer (better!)
function useInfiniteScroll(callback: () => void) {
    const observerRef = useRef<IntersectionObserver | null>(null);
    const loadMoreRef = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        observerRef.current = new IntersectionObserver(
            (entries) => {
                // If the sentinel element is visible, load more
                if (entries[0].isIntersecting) {
                    callback();
                }
            },
            { threshold: 1.0 } // Fully visible
        );

        if (loadMoreRef.current) {
            observerRef.current.observe(loadMoreRef.current);
        }

        return () => {
            if (observerRef.current) {
                observerRef.current.disconnect();
            }
        };
    }, [callback]);

    return loadMoreRef;
}

Pattern 1: Basic Infinite Scroll

โœ… Complete Infinite Scroll Component

interface Post {
    id: number;
    title: string;
    content: string;
}

const InfinitePostFeed: React.FC = () => {
    const [posts, setPosts] = useState<Post[]>([]);
    const [page, setPage] = useState(1);
    const [loading, setLoading] = useState(false);
    const [hasMore, setHasMore] = useState(true);
    const [error, setError] = useState<string | null>(null);

    const loadMorePosts = useCallback(async () => {
        if (loading || !hasMore) return;

        setLoading(true);
        setError(null);

        try {
            const response = await fetch(`/api/posts?page=${page}&limit=20`);
            
            if (!response.ok) {
                throw new Error('Failed to fetch posts');
            }

            const data = await response.json();
            
            if (data.posts.length === 0) {
                setHasMore(false);
            } else {
                setPosts(prev => [...prev, ...data.posts]);
                setPage(prev => prev + 1);
            }
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Unknown error');
        } finally {
            setLoading(false);
        }
    }, [page, loading, hasMore]);

    // Load initial data
    useEffect(() => {
        loadMorePosts();
    }, []); // Empty deps - only load once on mount

    // Intersection Observer for infinite scroll
    const observerTarget = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && hasMore && !loading) {
                    loadMorePosts();
                }
            },
            { threshold: 1.0 }
        );

        if (observerTarget.current) {
            observer.observe(observerTarget.current);
        }

        return () => {
            if (observerTarget.current) {
                observer.unobserve(observerTarget.current);
            }
        };
    }, [loadMorePosts, hasMore, loading]);

    return (
        <div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
            <h1>Infinite Post Feed</h1>

            {/* Posts */}
            <div>
                {posts.map(post => (
                    <article 
                        key={post.id}
                        style={{
                            border: '1px solid #ddd',
                            borderRadius: '8px',
                            padding: '1.5rem',
                            marginBottom: '1rem'
                        }}
                    >
                        <h2>{post.title}</h2>
                        <p>{post.content}</p>
                    </article>
                ))}
            </div>

            {/* Loading indicator */}
            {loading && (
                <div style={{ textAlign: 'center', padding: '2rem' }}>
                    <p>Loading more posts...</p>
                </div>
            )}

            {/* Error message */}
            {error && (
                <div style={{ color: 'red', textAlign: 'center', padding: '2rem' }}>
                    Error: {error}
                </div>
            )}

            {/* End of content message */}
            {!hasMore && (
                <div style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
                    <p>๐ŸŽ‰ You've reached the end!</p>
                </div>
            )}

            {/* Intersection Observer target (sentinel element) */}
            <div 
                ref={observerTarget}
                style={{ height: '20px' }}
            />
        </div>
    );
};

Pattern 2: Reusable Infinite Scroll Hook

Extract infinite scroll logic into a custom hook:

interface UseInfiniteScrollOptions<T> {
    fetchFunction: (page: number) => Promise<T[]>;
    initialPage?: number;
    threshold?: number;
}

interface UseInfiniteScrollReturn<T> {
    items: T[];
    loading: boolean;
    error: Error | null;
    hasMore: boolean;
    loadMore: () => void;
    reset: () => void;
    observerTarget: React.RefObject<HTMLDivElement>;
}

function useInfiniteScroll<T>(
    options: UseInfiniteScrollOptions<T>
): UseInfiniteScrollReturn<T> {
    const { fetchFunction, initialPage = 1, threshold = 1.0 } = options;

    const [items, setItems] = useState<T[]>([]);
    const [page, setPage] = useState(initialPage);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
    const [hasMore, setHasMore] = useState(true);
    const observerTarget = useRef<HTMLDivElement>(null);

    const loadMore = useCallback(async () => {
        if (loading || !hasMore) return;

        setLoading(true);
        setError(null);

        try {
            const newItems = await fetchFunction(page);
            
            if (newItems.length === 0) {
                setHasMore(false);
            } else {
                setItems(prev => [...prev, ...newItems]);
                setPage(prev => prev + 1);
            }
        } catch (err) {
            setError(err as Error);
        } finally {
            setLoading(false);
        }
    }, [fetchFunction, page, loading, hasMore]);

    const reset = useCallback(() => {
        setItems([]);
        setPage(initialPage);
        setHasMore(true);
        setError(null);
    }, [initialPage]);

    // Intersection Observer setup
    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && hasMore && !loading) {
                    loadMore();
                }
            },
            { threshold }
        );

        const currentTarget = observerTarget.current;
        if (currentTarget) {
            observer.observe(currentTarget);
        }

        return () => {
            if (currentTarget) {
                observer.unobserve(currentTarget);
            }
        };
    }, [loadMore, hasMore, loading, threshold]);

    // Load initial data
    useEffect(() => {
        if (items.length === 0 && page === initialPage) {
            loadMore();
        }
    }, []);

    return {
        items,
        loading,
        error,
        hasMore,
        loadMore,
        reset,
        observerTarget
    };
}

// Usage - Much cleaner!
const PostFeed: React.FC = () => {
    const fetchPosts = async (page: number) => {
        const response = await fetch(`/api/posts?page=${page}&limit=20`);
        const data = await response.json();
        return data.posts;
    };

    const {
        items: posts,
        loading,
        error,
        hasMore,
        observerTarget
    } = useInfiniteScroll<Post>({ fetchFunction: fetchPosts });

    return (
        <div>
            {posts.map(post => (
                <article key={post.id}>
                    <h2>{post.title}</h2>
                    <p>{post.content}</p>
                </article>
            ))}

            {loading && <div>Loading...</div>}
            {error && <div>Error: {error.message}</div>}
            {!hasMore && <div>No more posts!</div>}

            <div ref={observerTarget} style={{ height: '20px' }} />
        </div>
    );
};

Pattern 3: Infinite Scroll with Search/Filter

Combine infinite scroll with search or filters:

const SearchablePostFeed: React.FC = () => {
    const [searchQuery, setSearchQuery] = useState('');
    const debouncedQuery = useDebounce(searchQuery, 500);

    const fetchPosts = useCallback(async (page: number) => {
        const params = new URLSearchParams({
            page: page.toString(),
            limit: '20',
            q: debouncedQuery
        });

        const response = await fetch(`/api/posts?${params}`);
        const data = await response.json();
        return data.posts;
    }, [debouncedQuery]);

    const {
        items: posts,
        loading,
        error,
        hasMore,
        reset,
        observerTarget
    } = useInfiniteScroll<Post>({ fetchFunction: fetchPosts });

    // Reset when search query changes
    useEffect(() => {
        reset();
    }, [debouncedQuery, reset]);

    return (
        <div>
            <input
                type="text"
                value={searchQuery}
                onChange={(e) => setSearchQuery(e.target.value)}
                placeholder="Search posts..."
                style={{
                    width: '100%',
                    padding: '0.75rem',
                    fontSize: '1rem',
                    marginBottom: '2rem'
                }}
            />

            {posts.length === 0 && !loading && (
                <div>No posts found.</div>
            )}

            {posts.map(post => (
                <article key={post.id}>
                    <h2>{post.title}</h2>
                    <p>{post.content}</p>
                </article>
            ))}

            {loading && <div>Loading...</div>}
            {error && <div>Error: {error.message}</div>}
            {!hasMore && posts.length > 0 && <div>No more results!</div>}

            <div ref={observerTarget} style={{ height: '20px' }} />
        </div>
    );
};

๐Ÿ’ก Infinite Scroll Best Practices

  • Use Intersection Observer: More performant than scroll event listeners
  • Show loading states: Users need feedback that more content is loading
  • Handle "no more data": Show a clear end message
  • Provide "Back to Top" button: For long scrolls
  • Reset on filter change: Clear items when search/filter changes
  • Consider accessibility: Provide keyboard navigation alternatives
  • Throttle loading: Prevent rapid-fire requests
  • Save scroll position: When navigating back, restore position

โš ๏ธ When NOT to Use Infinite Scroll

  • Footer is important: Users can never reach a footer with infinite scroll!
  • Specific items needed: Finding item #237 is frustrating with infinite scroll
  • Goal-oriented tasks: E-commerce checkout flows work better with pagination
  • Print/export needed: Paginated data is easier to export

Solution: Provide pagination as an alternative, or use "Load More" button instead of automatic loading.

๐Ÿ’พ Client-Side Caching

Why fetch the same data multiple times? Caching stores fetched data so you can reuse it without making redundant API calls. Let's build a smart caching system!

๐Ÿ“– Definition

Client-Side Caching: Storing API responses in memory (or browser storage) to avoid redundant network requests. This improves performance, reduces server load, and provides instant data access for frequently accessed resources.

Why Caching Matters

Without Caching:

// User visits /users/1 โ†’ API call
// User clicks "Back" โ†’ API call
// User visits /users/1 again โ†’ API call
// User switches tabs, comes back โ†’ API call
// 
// Result: 4 identical API calls for the same data! โŒ

With Caching:

// User visits /users/1 โ†’ API call, cache result
// User clicks "Back" โ†’ Use cached data (instant!)
// User visits /users/1 again โ†’ Use cached data (instant!)
// User switches tabs, comes back โ†’ Use cached data (instant!)
// 
// Result: 1 API call, 3 instant cache hits! โœ…

Pattern 1: Simple In-Memory Cache

โœ… Basic Cache Implementation

// Simple cache object
const cache = new Map<string, any>();

function useCachedFetch<T>(url: string) {
    const [data, setData] = useState<T | null>(() => {
        // Check cache first
        return cache.get(url) || null;
    });
    const [loading, setLoading] = useState(!cache.has(url));
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        // If data is in cache, don't fetch
        if (cache.has(url)) {
            return;
        }

        const controller = new AbortController();
        
        setLoading(true);

        fetch(url, { signal: controller.signal })
            .then(res => res.json())
            .then(result => {
                cache.set(url, result); // Store in cache
                setData(result);
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    setError(err);
                }
            })
            .finally(() => {
                setLoading(false);
            });

        return () => controller.abort();
    }, [url]);

    return { data, loading, error };
}

// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const { data: user, loading } = useCachedFetch<User>(`/api/users/${userId}`);

    // First visit: loading = true, fetches data
    // Subsequent visits: loading = false, instant data! โœ…

    if (loading) return <div>Loading...</div>;
    return <div>{user?.name}</div>;
};

Pattern 2: Cache with Expiration

Cached data can become stale. Let's add expiration:

interface CacheEntry<T> {
    data: T;
    timestamp: number;
}

class Cache {
    private cache = new Map<string, CacheEntry<any>>();
    private defaultTTL = 5 * 60 * 1000; // 5 minutes

    set<T>(key: string, data: T, ttl?: number): void {
        this.cache.set(key, {
            data,
            timestamp: Date.now()
        });
    }

    get<T>(key: string, ttl?: number): T | null {
        const entry = this.cache.get(key);
        
        if (!entry) return null;

        const age = Date.now() - entry.timestamp;
        const maxAge = ttl || this.defaultTTL;

        // Check if expired
        if (age > maxAge) {
            this.cache.delete(key);
            return null;
        }

        return entry.data;
    }

    has(key: string, ttl?: number): boolean {
        return this.get(key, ttl) !== null;
    }

    clear(): void {
        this.cache.clear();
    }

    delete(key: string): void {
        this.cache.delete(key);
    }
}

// Global cache instance
const apiCache = new Cache();

function useCachedFetch<T>(url: string, ttl?: number) {
    const [data, setData] = useState<T | null>(() => apiCache.get<T>(url, ttl));
    const [loading, setLoading] = useState(!apiCache.has(url, ttl));
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        // Check if valid cache exists
        const cached = apiCache.get<T>(url, ttl);
        if (cached) {
            setData(cached);
            setLoading(false);
            return;
        }

        const controller = new AbortController();
        
        setLoading(true);

        fetch(url, { signal: controller.signal })
            .then(res => res.json())
            .then(result => {
                apiCache.set(url, result, ttl);
                setData(result);
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    setError(err);
                }
            })
            .finally(() => {
                setLoading(false);
            });

        return () => controller.abort();
    }, [url, ttl]);

    const invalidate = useCallback(() => {
        apiCache.delete(url);
        setData(null);
    }, [url]);

    return { data, loading, error, invalidate };
}

// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const { 
        data: user, 
        loading, 
        invalidate 
    } = useCachedFetch<User>(
        `/api/users/${userId}`,
        2 * 60 * 1000 // 2 minute cache
    );

                
                

๐ŸŽฎ Interactive: Cache Hit/Miss Simulator

Click "Fetch" buttons to see how caching works. First request is a cache miss (slow), subsequent requests are cache hits (instant)!

Client Cache (empty) TTL: -- Server request HIT! โšก MISS โ†’ response Click a fetch button to start Response time: --
Cache Hits: 0
Cache Misses: 0
return ( <div> {loading && <div>Loading...</div>} {user && ( <div> <h2>{user.name}</h2> <button onClick={invalidate}>Refresh</button> </div> )} </div> ); };

Pattern 3: Persistent Cache with localStorage

Persist cache across page reloads:

class PersistentCache extends Cache {
    private storageKey = 'api_cache';

    constructor() {
        super();
        this.loadFromStorage();
    }

    private loadFromStorage(): void {
        try {
            const stored = localStorage.getItem(this.storageKey);
            if (stored) {
                const data = JSON.parse(stored);
                // Restore cache from localStorage
                Object.entries(data).forEach(([key, value]) => {
                    this.cache.set(key, value as CacheEntry<any>);
                });
            }
        } catch (error) {
            console.error('Failed to load cache from storage:', error);
        }
    }

    private saveToStorage(): void {
        try {
            const data = Object.fromEntries(this.cache.entries());
            localStorage.setItem(this.storageKey, JSON.stringify(data));
        } catch (error) {
            console.error('Failed to save cache to storage:', error);
        }
    }

    set<T>(key: string, data: T, ttl?: number): void {
        super.set(key, data, ttl);
        this.saveToStorage();
    }

    delete(key: string): void {
        super.delete(key);
        this.saveToStorage();
    }

    clear(): void {
        super.clear();
        localStorage.removeItem(this.storageKey);
    }
}

// Now cache persists across page reloads! โœ…
const persistentCache = new PersistentCache();

Cache Invalidation Strategies

๐Ÿ’ก When to Invalidate Cache

  • Time-based (TTL): Expire after X minutes
  • On mutation: Clear cache after POST/PUT/DELETE
  • Manual refresh: User clicks "Refresh" button
  • Background revalidation: Fetch in background, update when ready
  • Optimistic updates: Update cache immediately, sync with server
// Invalidate on mutation
const useUpdateUser = () => {
    const updateUser = async (userId: string, updates: Partial<User>) => {
        const response = await fetch(`/api/users/${userId}`, {
            method: 'PUT',
            body: JSON.stringify(updates)
        });

        if (response.ok) {
            // Invalidate cache for this user
            apiCache.delete(`/api/users/${userId}`);
            // Could also invalidate list caches
            apiCache.delete('/api/users');
        }

        return response.json();
    };

    return { updateUser };
};

โœ… Caching Best Practices

  • Use appropriate TTLs: Frequently changing data needs shorter TTLs
  • Invalidate on mutations: Clear cache after creating/updating/deleting
  • Cache key strategy: Include query params in cache keys
  • Memory limits: Don't cache everythingโ€”prioritize frequently accessed data
  • Show stale data: Display cached data while revalidating in background
  • Handle cache errors: Gracefully degrade if cache fails

โš ๏ธ Cache Pitfalls to Avoid

  • Caching user-specific data globally: User A sees User B's data!
  • No expiration: Stale data forever
  • Caching errors: Don't cache failed requests
  • Memory leaks: Unlimited cache growth crashes browser

๐Ÿ”ฎ Introduction to React Query

We've built custom hooks for caching, pagination, and infinite scroll. But what if there was a library that handled all of this (and more) out of the box? Meet React Query (TanStack Query)โ€”the most powerful data fetching library for React!

๐Ÿ“– What is React Query?

React Query (TanStack Query): A powerful data synchronization library that handles caching, background updates, stale data revalidation, pagination, and moreโ€”all with minimal code. It treats server state as fundamentally different from client state and provides tools specifically for managing it.

Why React Query?

What We've Built vs React Query:

Feature Custom Hooks React Query
Caching 50+ lines of code Built-in, automatic
Stale data Manual TTL management Automatic revalidation
Background refetch Custom implementation Automatic on window focus
Pagination Custom state management Built-in helpers
Infinite scroll Complex state logic useInfiniteQuery hook
Optimistic updates Manual cache updates Built-in mutation tools
Request deduplication Not handled Automatic

Setup React Query

// Install
npm install @tanstack/react-query

// Setup in your app
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Create a query client
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 5 * 60 * 1000, // 5 minutes
            cacheTime: 10 * 60 * 1000, // 10 minutes
            retry: 3,
            refetchOnWindowFocus: true,
        },
    },
});

// Wrap your app
function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <YourApp />
            <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
    );
}

Basic Usage: useQuery

Replace your custom fetch hooks with useQuery:

โœ… Before (Custom Hook)

const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const [user, setUser] = useState<User | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
        const controller = new AbortController();
        
        fetch(`/api/users/${userId}`, { signal: controller.signal })
            .then(res => res.json())
            .then(setUser)
            .catch(setError)
            .finally(() => setLoading(false));

        return () => controller.abort();
    }, [userId]);

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    return <div>{user?.name}</div>;
};

โœ… After (React Query)

import { useQuery } from '@tanstack/react-query';

const fetchUser = async (userId: string): Promise<User> => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
        throw new Error('Failed to fetch user');
    }
    return response.json();
};

const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const { data: user, isLoading, error } = useQuery({
        queryKey: ['user', userId], // Unique key for this query
        queryFn: () => fetchUser(userId), // Function to fetch data
    });

    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    return <div>{user?.name}</div>;
};

// Benefits:
// โœ… Automatic caching
// โœ… Automatic request cancellation
// โœ… Automatic background refetch
// โœ… Request deduplication
// โœ… Much less code!

Mutations with useMutation

Handle POST/PUT/DELETE operations:

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface UpdateUserData {
    name: string;
    email: string;
}

const updateUser = async (userId: string, data: UpdateUserData): Promise<User> => {
    const response = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
    });
    
    if (!response.ok) {
        throw new Error('Failed to update user');
    }
    
    return response.json();
};

const UserEditForm: React.FC<{ userId: string }> = ({ userId }) => {
    const queryClient = useQueryClient();
    
    const mutation = useMutation({
        mutationFn: (data: UpdateUserData) => updateUser(userId, data),
        onSuccess: (updatedUser) => {
            // Invalidate and refetch user query
            queryClient.invalidateQueries({ queryKey: ['user', userId] });
            
            // Or update cache directly (optimistic update)
            queryClient.setQueryData(['user', userId], updatedUser);
        },
    });

    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        
        mutation.mutate({
            name: formData.get('name') as string,
            email: formData.get('email') as string,
        });
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="name" placeholder="Name" />
            <input name="email" type="email" placeholder="Email" />
            <button type="submit" disabled={mutation.isPending}>
                {mutation.isPending ? 'Saving...' : 'Save'}
            </button>
            
            {mutation.isError && (
                <div style={{ color: 'red' }}>
                    Error: {mutation.error.message}
                </div>
            )}
            
            {mutation.isSuccess && (
                <div style={{ color: 'green' }}>
                    User updated successfully!
                </div>
            )}
        </form>
    );
};

Pagination with React Query

const ProductList: React.FC = () => {
    const [page, setPage] = useState(1);

    const { data, isLoading, isPlaceholderData } = useQuery({
        queryKey: ['products', page],
        queryFn: () => fetchProducts(page),
        placeholderData: (previousData) => previousData, // Keep old data while loading
    });

    return (
        <div>
            {isLoading ? (
                <div>Loading...</div>
            ) : (
                <div>
                    {data?.products.map(product => (
                        <div key={product.id}>{product.name}</div>
                    ))}
                </div>
            )}

            <div>
                <button 
                    onClick={() => setPage(p => p - 1)}
                    disabled={page === 1}
                >
                    Previous
                </button>
                
                <span>Page {page}</span>
                
                <button
                    onClick={() => setPage(p => p + 1)}
                    disabled={isPlaceholderData || !data?.hasMore}
                >
                    Next
                </button>
            </div>
        </div>
    );
};

Infinite Scroll with React Query

import { useInfiniteQuery } from '@tanstack/react-query';

interface PageParam {
    pageParam: number;
}

const PostFeed: React.FC = () => {
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
        isLoading,
    } = useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
        getNextPageParam: (lastPage, allPages) => {
            return lastPage.hasMore ? allPages.length + 1 : undefined;
        },
        initialPageParam: 1,
    });

    const observerTarget = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
                    fetchNextPage();
                }
            },
            { threshold: 1.0 }
        );

        if (observerTarget.current) {
            observer.observe(observerTarget.current);
        }

        return () => observer.disconnect();
    }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

    if (isLoading) return <div>Loading...</div>;

    return (
        <div>
            {data?.pages.map((page, i) => (
                <div key={i}>
                    {page.posts.map(post => (
                        <article key={post.id}>
                            <h2>{post.title}</h2>
                            <p>{post.content}</p>
                        </article>
                    ))}
                </div>
            ))}

            {isFetchingNextPage && <div>Loading more...</div>}
            {!hasNextPage && <div>No more posts!</div>}

            <div ref={observerTarget} style={{ height: '20px' }} />
        </div>
    );
};

๐Ÿ’ก React Query Superpowers

  • Automatic caching: No manual cache management needed
  • Background refetching: Keep data fresh automatically
  • Window focus refetch: Refetch when user returns to tab
  • Request deduplication: Multiple components requesting same data? One request.
  • Retry logic: Automatic retries with exponential backoff
  • Prefetching: Load data before user needs it
  • DevTools: Beautiful debugging interface
  • TypeScript first: Excellent type inference

โœ… When to Use React Query

  • Any app that fetches data from APIs
  • Apps with frequent data updates
  • Apps with complex caching requirements
  • When you need pagination or infinite scroll
  • When you want optimistic updates
  • When you need background data synchronization

Bottom line: If you're fetching data in React, React Query will make your life easier!

โšก Optimistic Updates

Why make users wait to see their changes? Optimistic updates instantly show changes in the UI before the server responds, creating a lightning-fast user experience!

๐Ÿ“– Definition

Optimistic Update: A UX pattern where the UI is immediately updated to reflect a user's action, assuming the server operation will succeed. If it fails, the UI rolls back to the previous state. This creates the illusion of instant responses.

How Optimistic Updates Work

sequenceDiagram participant User participant UI participant Cache participant Server User->>UI: Clicks "Like" button UI->>Cache: Update count: 5 โ†’ 6 (instant!) UI->>User: Shows liked state immediately โšก UI->>Server: Send like request Note over Server: Processing... alt Success Server->>UI: 200 OK Note over UI: Keep optimistic update โœ… else Failure Server->>UI: 500 Error UI->>Cache: Rollback: 6 โ†’ 5 UI->>User: Show error, revert button โŒ end

Pattern 1: Manual Optimistic Update

const LikeButton: React.FC<{ postId: string; initialLikes: number }> = ({
    postId,
    initialLikes
}) => {
    const [likes, setLikes] = useState(initialLikes);
    const [isLiked, setIsLiked] = useState(false);
    const [isUpdating, setIsUpdating] = useState(false);

    const handleLike = async () => {
        // Save previous state for rollback
        const previousLikes = likes;
        const previousIsLiked = isLiked;

        // Optimistic update - instant UI change!
        setLikes(prev => isLiked ? prev - 1 : prev + 1);
        setIsLiked(prev => !prev);
        setIsUpdating(true);

        try {
            // Send request to server
            const response = await fetch(`/api/posts/${postId}/like`, {
                method: 'POST',
                body: JSON.stringify({ liked: !isLiked })
            });

            if (!response.ok) {
                throw new Error('Failed to update like');
            }

            // Success! Keep the optimistic update
            const data = await response.json();
            setLikes(data.likes); // Sync with server's actual count
        } catch (error) {
            // Failure! Rollback to previous state
            setLikes(previousLikes);
            setIsLiked(previousIsLiked);
            console.error('Failed to like post:', error);
        } finally {
            setIsUpdating(false);
        }
    };

    return (
        <button 
            onClick={handleLike} 
            disabled={isUpdating}
            style={{
                backgroundColor: isLiked ? '#667eea' : '#ccc',
                color: 'white',
                padding: '0.5rem 1rem',
                border: 'none',
                borderRadius: '4px',
                cursor: isUpdating ? 'not-allowed' : 'pointer'
            }}
        >
            ๐Ÿ‘ {likes} {isLiked ? 'Liked' : 'Like'}
        </button>
    );
};

Pattern 2: Optimistic Updates with React Query

React Query makes optimistic updates much easier:

โœ… React Query Optimistic Update

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Post {
    id: string;
    likes: number;
    isLiked: boolean;
}

const LikeButton: React.FC<{ post: Post }> = ({ post }) => {
    const queryClient = useQueryClient();

    const likeMutation = useMutation({
        mutationFn: async (postId: string) => {
            const response = await fetch(`/api/posts/${postId}/like`, {
                method: 'POST',
            });
            return response.json();
        },
        
        // Called before mutation
        onMutate: async (postId) => {
            // Cancel outgoing queries
            await queryClient.cancelQueries({ queryKey: ['post', postId] });

            // Snapshot previous value for rollback
            const previousPost = queryClient.getQueryData<Post>(['post', postId]);

            // Optimistically update cache
            queryClient.setQueryData<Post>(['post', postId], (old) => {
                if (!old) return old;
                return {
                    ...old,
                    likes: old.isLiked ? old.likes - 1 : old.likes + 1,
                    isLiked: !old.isLiked
                };
            });

            // Return context with previous value for rollback
            return { previousPost };
        },

        // Called on error
        onError: (err, postId, context) => {
            // Rollback to previous value
            if (context?.previousPost) {
                queryClient.setQueryData(['post', postId], context.previousPost);
            }
        },

        // Always refetch after error or success
        onSettled: (data, error, postId) => {
            queryClient.invalidateQueries({ queryKey: ['post', postId] });
        },
    });

    return (
        <button 
            onClick={() => likeMutation.mutate(post.id)}
            disabled={likeMutation.isPending}
            style={{
                backgroundColor: post.isLiked ? '#667eea' : '#ccc',
                color: 'white',
                padding: '0.5rem 1rem',
                border: 'none',
                borderRadius: '4px'
            }}
        >
            ๐Ÿ‘ {post.likes} {post.isLiked ? 'Liked' : 'Like'}
        </button>
    );
};

Pattern 3: Optimistic List Updates

Adding/removing items from lists optimistically:

interface Todo {
    id: string;
    text: string;
    completed: boolean;
}

const TodoList: React.FC = () => {
    const queryClient = useQueryClient();

    // Add todo optimistically
    const addMutation = useMutation({
        mutationFn: async (text: string) => {
            const response = await fetch('/api/todos', {
                method: 'POST',
                body: JSON.stringify({ text })
            });
            return response.json();
        },
        
        onMutate: async (text) => {
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

            // Create temporary todo with fake ID
            const optimisticTodo: Todo = {
                id: `temp-${Date.now()}`,
                text,
                completed: false
            };

            // Add to cache immediately
            queryClient.setQueryData<Todo[]>(['todos'], (old = []) => 
                [...old, optimisticTodo]
            );

            return { previousTodos };
        },

        onError: (err, text, context) => {
            if (context?.previousTodos) {
                queryClient.setQueryData(['todos'], context.previousTodos);
            }
        },

        onSuccess: (newTodo) => {
            // Replace temporary todo with real one from server
            queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
                old.map(todo => 
                    todo.id.startsWith('temp-') ? newTodo : todo
                )
            );
        },
    });

    // Delete todo optimistically
    const deleteMutation = useMutation({
        mutationFn: async (id: string) => {
            await fetch(`/api/todos/${id}`, { method: 'DELETE' });
        },
        
        onMutate: async (id) => {
            await queryClient.cancelQueries({ queryKey: ['todos'] });
            const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

            // Remove from cache immediately
            queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
                old.filter(todo => todo.id !== id)
            );

            return { previousTodos };
        },

        onError: (err, id, context) => {
            if (context?.previousTodos) {
                queryClient.setQueryData(['todos'], context.previousTodos);
            }
        },
    });

    const { data: todos = [] } = useQuery({
        queryKey: ['todos'],
        queryFn: fetchTodos,
    });

    return (
        <div>
            {todos.map(todo => (
                <div key={todo.id}>
                    <span>{todo.text}</span>
                    <button onClick={() => deleteMutation.mutate(todo.id)}>
                        Delete
                    </button>
                </div>
            ))}
            
            <button onClick={() => addMutation.mutate('New todo')}>
                Add Todo
            </button>
        </div>
    );
};

๐Ÿ’ก Optimistic Update Best Practices

  • Always provide rollback: If the mutation fails, restore previous state
  • Show pending states: Disable buttons or show spinners during mutations
  • Handle errors gracefully: Show clear error messages when rollback occurs
  • Cancel in-flight queries: Prevent race conditions with query cancellation
  • Sync with server: Always refetch after mutations to ensure data consistency
  • Use temporary IDs: For new items, use temp IDs until server responds

โš ๏ธ When NOT to Use Optimistic Updates

  • Critical operations: Financial transactions, medical records
  • Complex validations: When server might reject for non-obvious reasons
  • Multi-user conflicts: When concurrent edits are likely
  • Slow rollback UX: When reverting would be jarring or confusing

Rule of thumb: Use optimistic updates for simple, likely-to-succeed operations where instant feedback improves UX!

๐Ÿ‹๏ธ Hands-on Practice

Time to apply everything you've learned! These exercises will solidify your advanced data fetching skills.

๐Ÿ‹๏ธ Exercise 1: Debounced Search with Cache

Build a search component that debounces user input and caches results.

Requirements:

  • Search input that debounces by 500ms
  • Fetch search results from an API
  • Cache results for each search query
  • Show loading state while fetching
  • Handle race conditions with AbortController
  • Display search results in a list
  • Type everything properly with TypeScript
๐Ÿ’ก Hint

Combine your useDebounce hook with a caching strategy. Use the debounced value as the cache key. Don't forget to abort old requests when the query changes!

โœ… Solution
interface SearchResult {
    id: number;
    title: string;
    description: string;
}

// Simple cache
const searchCache = new Map<string, SearchResult[]>();

const SearchComponent: React.FC = () => {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 500);
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        if (!debouncedQuery) {
            setResults([]);
            return;
        }

        // Check cache first
        const cached = searchCache.get(debouncedQuery);
        if (cached) {
            setResults(cached);
            return;
        }

        const controller = new AbortController();
        setLoading(true);
        setError(null);

        fetch(`/api/search?q=${debouncedQuery}`, {
            signal: controller.signal
        })
            .then(res => res.json())
            .then(data => {
                searchCache.set(debouncedQuery, data.results);
                setResults(data.results);
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    setError('Search failed');
                }
            })
            .finally(() => setLoading(false));

        return () => controller.abort();
    }, [debouncedQuery]);

    return (
        <div>
            <input
                type="text"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
                style={{ width: '100%', padding: '0.75rem', fontSize: '1rem' }}
            />

            {loading && <div>Searching...</div>}
            {error && <div style={{ color: 'red' }}>{error}</div>}

            <div>
                {results.map(result => (
                    <div key={result.id} style={{ padding: '1rem', borderBottom: '1px solid #ddd' }}>
                        <h3>{result.title}</h3>
                        <p>{result.description}</p>
                    </div>
                ))}
            </div>
        </div>
    );
};

๐Ÿ‹๏ธ Exercise 2: Paginated Product Catalog

Build a product catalog with pagination and URL state management.

Requirements:

  • Display products in a grid layout
  • Implement pagination (previous/next buttons)
  • Show page numbers with the current page highlighted
  • Sync page number with URL query params
  • Disable buttons appropriately (first/last page)
  • Show loading state while fetching
  • Handle errors gracefully
  • Cancel old requests when page changes
๐Ÿ’ก Hint

Use React Router's useSearchParams to sync the page with the URL. Use AbortController in your useEffect cleanup. Keep track of total pages from the API response.

โœ… Solution
import { useSearchParams } from 'react-router-dom';

interface Product {
    id: number;
    name: string;
    price: number;
    image: string;
}

interface PaginationData {
    products: Product[];
    currentPage: number;
    totalPages: number;
    totalItems: number;
}

const ProductCatalog: React.FC = () => {
    const [searchParams, setSearchParams] = useSearchParams();
    const currentPage = Number(searchParams.get('page')) || 1;
    
    const [data, setData] = useState<PaginationData | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const controller = new AbortController();
        
        setLoading(true);
        setError(null);

        fetch(`/api/products?page=${currentPage}&limit=12`, {
            signal: controller.signal
        })
            .then(res => res.json())
            .then(setData)
            .catch(err => {
                if (err.name !== 'AbortError') {
                    setError('Failed to load products');
                }
            })
            .finally(() => setLoading(false));

        return () => controller.abort();
    }, [currentPage]);

    const goToPage = (page: number) => {
        setSearchParams({ page: page.toString() });
        window.scrollTo({ top: 0, behavior: 'smooth' });
    };

    if (error) return <div style={{ color: 'red' }}>{error}</div>;

    return (
        <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
            <h1>Product Catalog</h1>

            {loading && <div>Loading products...</div>}

            {data && (
                <>
                    <div style={{
                        display: 'grid',
                        gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
                        gap: '1.5rem',
                        marginBottom: '2rem'
                    }}>
                        {data.products.map(product => (
                            <div key={product.id} style={{
                                border: '1px solid #ddd',
                                borderRadius: '8px',
                                padding: '1rem',
                                textAlign: 'center'
                            }}>
                                <img 
                                    src={product.image} 
                                    alt={product.name}
                                    style={{ width: '100%', height: '200px', objectFit: 'cover' }}
                                />
                                <h3>{product.name}</h3>
                                <p style={{ fontSize: '1.25rem', fontWeight: 'bold', color: '#667eea' }}>
                                    ${product.price}
                                </p>
                            </div>
                        ))}
                    </div>

                    <div style={{
                        display: 'flex',
                        justifyContent: 'center',
                        alignItems: 'center',
                        gap: '1rem',
                        marginTop: '2rem'
                    }}>
                        <button
                            onClick={() => goToPage(currentPage - 1)}
                            disabled={currentPage === 1 || loading}
                            style={{
                                padding: '0.5rem 1rem',
                                backgroundColor: currentPage === 1 ? '#ccc' : '#667eea',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: currentPage === 1 ? 'not-allowed' : 'pointer'
                            }}
                        >
                            Previous
                        </button>

                        <div style={{ display: 'flex', gap: '0.5rem' }}>
                            {Array.from({ length: data.totalPages }, (_, i) => i + 1).map(page => (
                                <button
                                    key={page}
                                    onClick={() => goToPage(page)}
                                    disabled={loading}
                                    style={{
                                        padding: '0.5rem 1rem',
                                        backgroundColor: page === currentPage ? '#667eea' : 'white',
                                        color: page === currentPage ? 'white' : '#667eea',
                                        border: '1px solid #667eea',
                                        borderRadius: '4px',
                                        cursor: 'pointer',
                                        fontWeight: page === currentPage ? 'bold' : 'normal'
                                    }}
                                >
                                    {page}
                                </button>
                            ))}
                        </div>

                        <button
                            onClick={() => goToPage(currentPage + 1)}
                            disabled={currentPage === data.totalPages || loading}
                            style={{
                                padding: '0.5rem 1rem',
                                backgroundColor: currentPage === data.totalPages ? '#ccc' : '#667eea',
                                color: 'white',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: currentPage === data.totalPages ? 'not-allowed' : 'pointer'
                            }}
                        >
                            Next
                        </button>
                    </div>

                    <p style={{ textAlign: 'center', marginTop: '1rem', color: '#666' }}>
                        Page {currentPage} of {data.totalPages} ({data.totalItems} total products)
                    </p>
                </>
            )}
        </div>
    );
};

๐Ÿ‹๏ธ Exercise 3: Infinite Scroll Feed

Create an infinite scroll social media feed with loading states.

Requirements:

  • Load initial 20 posts on mount
  • Automatically load more when user scrolls near bottom
  • Use Intersection Observer for scroll detection
  • Show loading indicator while fetching
  • Display "No more posts" when all data is loaded
  • Prevent duplicate requests
  • Type all data properly
๐Ÿ’ก Hint

Use the useInfiniteScroll hook pattern we built earlier. Track hasMore state and check for empty responses. Place a sentinel div at the bottom to observe.

โœ… Solution
interface Post {
    id: string;
    author: string;
    content: string;
    timestamp: string;
}

const SocialFeed: React.FC = () => {
    const [posts, setPosts] = useState<Post[]>([]);
    const [page, setPage] = useState(1);
    const [loading, setLoading] = useState(false);
    const [hasMore, setHasMore] = useState(true);
    const observerTarget = useRef<HTMLDivElement>(null);

    const loadMorePosts = useCallback(async () => {
        if (loading || !hasMore) return;

        setLoading(true);

        try {
            const response = await fetch(`/api/posts?page=${page}&limit=20`);
            const data = await response.json();

            if (data.posts.length === 0) {
                setHasMore(false);
            } else {
                setPosts(prev => [...prev, ...data.posts]);
                setPage(prev => prev + 1);
            }
        } catch (error) {
            console.error('Failed to load posts:', error);
        } finally {
            setLoading(false);
        }
    }, [page, loading, hasMore]);

    // Load initial posts
    useEffect(() => {
        loadMorePosts();
    }, []);

    // Set up Intersection Observer
    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && hasMore && !loading) {
                    loadMorePosts();
                }
            },
            { threshold: 1.0 }
        );

        if (observerTarget.current) {
            observer.observe(observerTarget.current);
        }

        return () => {
            if (observerTarget.current) {
                observer.unobserve(observerTarget.current);
            }
        };
    }, [loadMorePosts, hasMore, loading]);

    return (
        <div style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
            <h1>Social Feed</h1>

            {posts.map(post => (
                <article 
                    key={post.id}
                    style={{
                        border: '1px solid #ddd',
                        borderRadius: '8px',
                        padding: '1.5rem',
                        marginBottom: '1rem'
                    }}
                >
                    <div style={{ display: 'flex', alignItems: 'center', marginBottom: '0.5rem' }}>
                        <strong>{post.author}</strong>
                        <span style={{ marginLeft: 'auto', color: '#666', fontSize: '0.875rem' }}>
                            {new Date(post.timestamp).toLocaleDateString()}
                        </span>
                    </div>
                    <p>{post.content}</p>
                </article>
            ))}

            {loading && (
                <div style={{ textAlign: 'center', padding: '2rem' }}>
                    <p>Loading more posts...</p>
                </div>
            )}

            {!hasMore && posts.length > 0 && (
                <div style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
                    <p>๐ŸŽ‰ You've reached the end!</p>
                </div>
            )}

            <div ref={observerTarget} style={{ height: '20px' }} />
        </div>
    );
};

๐Ÿ‹๏ธ Challenge: React Query Data Dashboard

Build a data dashboard using React Query with multiple queries, mutations, and optimistic updates.

Requirements:

  • Fetch and display user statistics (useQuery)
  • Fetch and display recent activity (useQuery)
  • Add action button with optimistic update (useMutation)
  • Implement background refetching
  • Show loading and error states
  • Add manual refresh button
  • Display last updated timestamp
  • Use React Query DevTools
๐Ÿ’ก Hint

Set up QueryClientProvider at the app root. Use separate useQuery calls for different data. Use useMutation with onMutate for optimistic updates. Remember to invalidate queries after mutations!

๐ŸŒŸ Best Practices

โœ… Do's

  • Always handle race conditions - Use AbortController or cleanup flags in every data fetching effect to prevent stale data from displaying.
  • Debounce user input - For search and autocomplete, wait 300-500ms after typing stops before firing requests. Saves bandwidth and improves UX.
  • Show loading states - Users need feedback. Show spinners, skeleton screens, or progress indicators during data fetching.
  • Cache intelligently - Cache frequently accessed, slowly changing data. Use appropriate TTLs based on how often data changes.
  • Invalidate cache on mutations - After POST/PUT/DELETE, clear related cache entries to ensure data consistency.
  • Use React Query for complex needs - Don't reinvent the wheel. React Query handles caching, refetching, and state management better than custom solutions.
  • Implement proper error handling - Show meaningful error messages. Provide retry buttons. Don't just console.log errors!
  • Type everything - Use TypeScript interfaces for API responses, cache entries, and hook return values.

โŒ Don'ts

  • Don't ignore cleanup - Always return cleanup functions from useEffect. Cancel requests, remove listeners, clear timers.
  • Don't fetch in render - Never call fetch directly in component body. Always use useEffect or event handlers.
  • Don't cache everything - User-specific data, real-time data, and large datasets often shouldn't be cached. Be selective.
  • Don't use infinite scroll everywhere - It's not appropriate for all UIs. Pagination works better when users need to find specific items.
  • Don't forget about mobile - Test debouncing delays and pagination on mobile devices. Network conditions vary widely.
  • Don't make assumptions about network speed - Always handle slow networks and timeouts gracefully.
  • Don't ignore failed optimistic updates - Always rollback and show errors when optimistic updates fail.

๐Ÿ’ก Pro Tips

  • Use request deduplication - If 5 components request the same data simultaneously, make only one request. React Query does this automatically!
  • Prefetch data - On hover or route navigation intent, prefetch data before it's needed for instant perceived performance.
  • Implement background revalidation - Show cached data immediately while fetching fresh data in the background.
  • Use polling for real-time updates - For data that updates frequently, implement smart polling with exponential backoff.
  • Add request timeouts - Combine AbortController with setTimeout for automatic request timeouts (e.g., 10 seconds).
  • Test with network throttling - Chrome DevTools lets you simulate slow 3G. Test your loading states and race conditions!
  • Monitor cache size - In memory-constrained environments, implement LRU (Least Recently Used) cache eviction.
  • Use skeleton screens - Instead of spinners, show content-shaped placeholders for better perceived performance.

โœ… Data Fetching Checklist

  • โœ… AbortController used for request cancellation
  • โœ… Debouncing implemented for search inputs
  • โœ… Loading states shown to users
  • โœ… Error handling with retry options
  • โœ… Cache strategy defined (TTL, invalidation)
  • โœ… Race conditions prevented
  • โœ… TypeScript types for all API responses
  • โœ… Pagination or infinite scroll implemented properly
  • โœ… Optimistic updates have rollback logic
  • โœ… Mobile experience tested

Performance Optimization Tips

Reducing Network Requests:

  • Batch requests: Combine multiple small requests into one larger request
  • GraphQL: For complex data needs, consider GraphQL to fetch exactly what you need
  • HTTP/2: Multiple concurrent requests are cheaper with HTTP/2
  • Compression: Ensure gzip/brotli compression is enabled on your API

Improving Perceived Performance:

  • Show cached data first: Display stale data while fetching fresh
  • Skeleton screens: Better than spinners for perceived speed
  • Optimistic updates: Make UI changes feel instant
  • Progressive loading: Load critical content first, lazy load the rest

๐Ÿ“š Summary

๐ŸŽ‰ Key Takeaways

  • Race conditions are silent bugs - Always use AbortController to cancel old requests when new ones start.
  • Debouncing saves bandwidth - Wait for users to stop typing before making API calls. 300-500ms is typical.
  • Request cancellation is essential - Cancel requests when components unmount or dependencies change.
  • Pagination comes in flavors - Offset-based, cursor-based, and page numbers each have their use cases.
  • Infinite scroll needs careful implementation - Use Intersection Observer, handle "no more data," and provide accessibility alternatives.
  • Client-side caching improves performance - Cache with TTL, invalidate on mutations, and use localStorage for persistence.
  • React Query is a game changer - It handles caching, refetching, pagination, and more with minimal code.
  • Optimistic updates feel instant - Update UI immediately, then sync with server. Always provide rollback for failures.
  • TypeScript makes data fetching safer - Type your API responses, cache entries, and hook return values.
  • Test with slow networks - Use Chrome DevTools throttling to test loading states and race conditions.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Congratulations! You've mastered advanced data fetching patterns that are used in production applications at major companies. You can now build fast, efficient, user-friendly applications that handle data like a pro.

In the next lesson, we'll work on the Module 4 Project: Weather Dashboard, where you'll apply everything you've learned:

  • Data fetching with proper error handling
  • Search with debouncing
  • Caching weather data
  • Loading and error states
  • Real-world API integration

๐ŸŽฏ Quick Quiz

Question 1: What is a race condition in data fetching?

Question 2: What's the purpose of debouncing in search inputs?

Question 3: What's the main advantage of using React Query over custom hooks?

Question 4: When should you use infinite scroll instead of pagination?

Question 5: What's an optimistic update?

๐ŸŽ‰ Congratulations!

You've completed Lesson 4.4: Advanced Data Fetching! You now have professional-grade data fetching skills that will serve you throughout your React career.

You've learned techniques used by major tech companies to build fast, efficient, user-friendly applications. Keep practicing! ๐Ÿš€