Skip to content

Data Fetching

Modern React applications need to fetch data from APIs. In this tutorial, you'll learn different approaches to data fetching, from basic fetch to custom hooks and state management.

Fetching Data with useEffect

Basic Fetch Pattern

jsx
import { useState, useEffect } from 'react';

function UserList() {
    const [users, setUsers] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users')
            .then(response => {
                if (!response.ok) {
                    throw new Error('Failed to fetch users');
                }
                return response.json();
            })
            .then(data => {
                setUsers(data);
                setLoading(false);
            })
            .catch(err => {
                setError(err.message);
                setLoading(false);
            });
    }, []); // Empty array = run once on mount

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}
tsx
import { useState, useEffect } from 'react';

interface User {
    id: number;
    name: string;
    email: string;
}

function UserList(): JSX.Element {
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users')
            .then((response: Response) => {
                if (!response.ok) {
                    throw new Error('Failed to fetch users');
                }
                return response.json();
            })
            .then((data: User[]) => {
                setUsers(data);
                setLoading(false);
            })
            .catch((err: Error) => {
                setError(err.message);
                setLoading(false);
            });
    }, []); // Empty array = run once on mount

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return (
        <ul>
            {users.map((user: User) => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

With Async/Await

jsx
function Posts() {
    const [posts, setPosts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        const fetchPosts = async () => {
            try {
                const response = await fetch('https://api.example.com/posts');

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

                const data = await response.json();
                setPosts(data);
            } catch (err) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };

        fetchPosts();
    }, []);

    // Render logic...
}
tsx
import { useState, useEffect } from 'react';

interface Post {
    id: number;
    title: string;
    body: string;
    userId: number;
}

function Posts(): JSX.Element {
    const [posts, setPosts] = useState<Post[]>([]);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const fetchPosts = async (): Promise<void> => {
            try {
                const response: Response = await fetch('https://api.example.com/posts');

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

                const data: Post[] = await response.json();
                setPosts(data);
            } catch (err) {
                setError(err instanceof Error ? err.message : 'Unknown error');
            } finally {
                setLoading(false);
            }
        };

        fetchPosts();
    }, []);

    // Render logic...
}

Handling Loading, Error, and Data States

┌─────────────────────────────────────────────────────────────┐
│                    Data Fetching States                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   Initial State                                             │
│   ┌─────────────────────────────────────────────────────┐  │
│   │  loading: true                                       │  │
│   │  error: null                                         │  │
│   │  data: null/[]                                       │  │
│   └─────────────────────────────────────────────────────┘  │
│                         ↓                                   │
│                    Fetch starts                             │
│                         ↓                                   │
│   ┌────────────────────┬────────────────────┐             │
│   │     Success        │      Failure       │             │
│   ├────────────────────┼────────────────────┤             │
│   │  loading: false    │  loading: false    │             │
│   │  error: null       │  error: "message"  │             │
│   │  data: [...]       │  data: null/[]     │             │
│   └────────────────────┴────────────────────┘             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
jsx
function DataDisplay() {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

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

    const fetchData = async () => {
        setLoading(true);
        setError(null);

        try {
            const response = await fetch('/api/data');
            if (!response.ok) throw new Error('Failed to fetch');
            const json = await response.json();
            setData(json);
        } catch (err) {
            setError(err.message);
        } finally {
            setLoading(false);
        }
    };

    // Loading state
    if (loading) {
        return (
            <div className="loading">
                <Spinner />
                <p>Loading data...</p>
            </div>
        );
    }

    // Error state
    if (error) {
        return (
            <div className="error">
                <p>Error: {error}</p>
                <button onClick={fetchData}>Try Again</button>
            </div>
        );
    }

    // Empty state
    if (!data || data.length === 0) {
        return (
            <div className="empty">
                <p>No data available</p>
            </div>
        );
    }

    // Success state
    return (
        <div className="data">
            {/* Render data */}
        </div>
    );
}
tsx
import { useState, useEffect } from 'react';

interface DataItem {
    id: number;
    name: string;
}

function DataDisplay(): JSX.Element {
    const [data, setData] = useState<DataItem[] | null>(null);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);

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

    const fetchData = async (): Promise<void> => {
        setLoading(true);
        setError(null);

        try {
            const response: Response = await fetch('/api/data');
            if (!response.ok) throw new Error('Failed to fetch');
            const json: DataItem[] = await response.json();
            setData(json);
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Unknown error');
        } finally {
            setLoading(false);
        }
    };

    // Loading state
    if (loading) {
        return (
            <div className="loading">
                <Spinner />
                <p>Loading data...</p>
            </div>
        );
    }

    // Error state
    if (error) {
        return (
            <div className="error">
                <p>Error: {error}</p>
                <button onClick={fetchData}>Try Again</button>
            </div>
        );
    }

    // Empty state
    if (!data || data.length === 0) {
        return (
            <div className="empty">
                <p>No data available</p>
            </div>
        );
    }

    // Success state
    return (
        <div className="data">
            {/* Render data */}
        </div>
    );
}

Cleanup and Race Conditions

Abort Controller

jsx
function SearchResults({ query }) {
    const [results, setResults] = useState([]);
    const [loading, setLoading] = useState(false);

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

        // Create abort controller
        const abortController = new AbortController();

        const search = async () => {
            setLoading(true);

            try {
                const response = await fetch(
                    `/api/search?q=${encodeURIComponent(query)}`,
                    { signal: abortController.signal }
                );

                if (!response.ok) throw new Error('Search failed');

                const data = await response.json();
                setResults(data);
            } catch (err) {
                // Ignore abort errors
                if (err.name !== 'AbortError') {
                    console.error('Search error:', err);
                }
            } finally {
                setLoading(false);
            }
        };

        search();

        // Cleanup: abort the request if query changes
        return () => {
            abortController.abort();
        };
    }, [query]);

    return (
        <div>
            {loading && <p>Searching...</p>}
            {results.map(item => (
                <div key={item.id}>{item.title}</div>
            ))}
        </div>
    );
}
tsx
import { useState, useEffect } from 'react';

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

interface SearchResultsProps {
    query: string;
}

function SearchResults({ query }: SearchResultsProps): JSX.Element {
    const [results, setResults] = useState<SearchResult[]>([]);
    const [loading, setLoading] = useState<boolean>(false);

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

        // Create abort controller
        const abortController = new AbortController();

        const search = async (): Promise<void> => {
            setLoading(true);

            try {
                const response: Response = await fetch(
                    `/api/search?q=${encodeURIComponent(query)}`,
                    { signal: abortController.signal }
                );

                if (!response.ok) throw new Error('Search failed');

                const data: SearchResult[] = await response.json();
                setResults(data);
            } catch (err) {
                // Ignore abort errors
                if (err instanceof Error && err.name !== 'AbortError') {
                    console.error('Search error:', err);
                }
            } finally {
                setLoading(false);
            }
        };

        search();

        // Cleanup: abort the request if query changes
        return () => {
            abortController.abort();
        };
    }, [query]);

    return (
        <div>
            {loading && <p>Searching...</p>}
            {results.map((item: SearchResult) => (
                <div key={item.id}>{item.title}</div>
            ))}
        </div>
    );
}

Ignore Stale Responses

jsx
function UserProfile({ userId }) {
    const [user, setUser] = useState(null);

    useEffect(() => {
        let ignore = false;

        const fetchUser = async () => {
            const response = await fetch(`/api/users/${userId}`);
            const data = await response.json();

            // Only update if this is still the current request
            if (!ignore) {
                setUser(data);
            }
        };

        fetchUser();

        return () => {
            ignore = true;
        };
    }, [userId]);

    return user ? <div>{user.name}</div> : <p>Loading...</p>;
}
tsx
import { useState, useEffect } from 'react';

interface User {
    id: number;
    name: string;
    email: string;
}

interface UserProfileProps {
    userId: number;
}

function UserProfile({ userId }: UserProfileProps): JSX.Element {
    const [user, setUser] = useState<User | null>(null);

    useEffect(() => {
        let ignore = false;

        const fetchUser = async (): Promise<void> => {
            const response: Response = await fetch(`/api/users/${userId}`);
            const data: User = await response.json();

            // Only update if this is still the current request
            if (!ignore) {
                setUser(data);
            }
        };

        fetchUser();

        return () => {
            ignore = true;
        };
    }, [userId]);

    return user ? <div>{user.name}</div> : <p>Loading...</p>;
}

Custom Fetch Hook

useFetch Hook

jsx
import { useState, useEffect, useCallback } from 'react';

function useFetch(url, options = {}) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    const fetchData = useCallback(async () => {
        const abortController = new AbortController();

        try {
            setLoading(true);
            setError(null);

            const response = await fetch(url, {
                ...options,
                signal: abortController.signal
            });

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

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

        return () => abortController.abort();
    }, [url, JSON.stringify(options)]);

    useEffect(() => {
        const cleanup = fetchData();
        return cleanup;
    }, [fetchData]);

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

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

// Usage
function ProductList() {
    const { data: products, loading, error, refetch } = useFetch('/api/products');

    if (loading) return <p>Loading...</p>;
    if (error) return <button onClick={refetch}>Retry</button>;

    return (
        <ul>
            {products?.map(product => (
                <li key={product.id}>{product.name}</li>
            ))}
        </ul>
    );
}
tsx
import { useState, useEffect, useCallback } from 'react';

interface UseFetchResult<T> {
    data: T | null;
    loading: boolean;
    error: string | null;
    refetch: () => void;
}

interface FetchOptions extends RequestInit {
    // Add any custom options here
}

function useFetch<T>(url: string, options: FetchOptions = {}): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);

    const fetchData = useCallback(async (): Promise<() => void> => {
        const abortController = new AbortController();

        try {
            setLoading(true);
            setError(null);

            const response: Response = await fetch(url, {
                ...options,
                signal: abortController.signal
            });

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

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

        return () => abortController.abort();
    }, [url, JSON.stringify(options)]);

    useEffect(() => {
        const cleanup = fetchData();
        return () => {
            cleanup.then(abort => abort());
        };
    }, [fetchData]);

    const refetch = useCallback((): void => {
        fetchData();
    }, [fetchData]);

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

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

function ProductList(): JSX.Element {
    const { data: products, loading, error, refetch } = useFetch<Product[]>('/api/products');

    if (loading) return <p>Loading...</p>;
    if (error) return <button onClick={refetch}>Retry</button>;

    return (
        <ul>
            {products?.map((product: Product) => (
                <li key={product.id}>{product.name}</li>
            ))}
        </ul>
    );
}

useApi Hook with Methods

jsx
function useApi() {
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    const request = useCallback(async (url, options = {}) => {
        setLoading(true);
        setError(null);

        try {
            const response = await fetch(url, {
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers
                },
                ...options
            });

            if (!response.ok) {
                const errorData = await response.json().catch(() => ({}));
                throw new Error(errorData.message || `HTTP ${response.status}`);
            }

            const data = await response.json();
            return data;
        } catch (err) {
            setError(err.message);
            throw err;
        } finally {
            setLoading(false);
        }
    }, []);

    const get = useCallback((url) => request(url), [request]);

    const post = useCallback((url, data) =>
        request(url, {
            method: 'POST',
            body: JSON.stringify(data)
        }), [request]);

    const put = useCallback((url, data) =>
        request(url, {
            method: 'PUT',
            body: JSON.stringify(data)
        }), [request]);

    const del = useCallback((url) =>
        request(url, { method: 'DELETE' }), [request]);

    return { get, post, put, del, loading, error };
}

// Usage
function TodoApp() {
    const { get, post, del, loading, error } = useApi();
    const [todos, setTodos] = useState([]);

    useEffect(() => {
        loadTodos();
    }, []);

    const loadTodos = async () => {
        const data = await get('/api/todos');
        setTodos(data);
    };

    const addTodo = async (text) => {
        const newTodo = await post('/api/todos', { text });
        setTodos(prev => [...prev, newTodo]);
    };

    const deleteTodo = async (id) => {
        await del(`/api/todos/${id}`);
        setTodos(prev => prev.filter(t => t.id !== id));
    };

    return (
        <div>
            {loading && <p>Loading...</p>}
            {error && <p>Error: {error}</p>}
            {/* Todo list UI */}
        </div>
    );
}
tsx
import { useState, useCallback, useEffect } from 'react';

interface UseApiResult {
    get: <T>(url: string) => Promise<T>;
    post: <T, D>(url: string, data: D) => Promise<T>;
    put: <T, D>(url: string, data: D) => Promise<T>;
    del: <T>(url: string) => Promise<T>;
    loading: boolean;
    error: string | null;
}

function useApi(): UseApiResult {
    const [loading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<string | null>(null);

    const request = useCallback(async <T>(
        url: string,
        options: RequestInit = {}
    ): Promise<T> => {
        setLoading(true);
        setError(null);

        try {
            const response: Response = await fetch(url, {
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers
                },
                ...options
            });

            if (!response.ok) {
                const errorData = await response.json().catch(() => ({}));
                throw new Error(errorData.message || `HTTP ${response.status}`);
            }

            const data: T = await response.json();
            return data;
        } catch (err) {
            const message = err instanceof Error ? err.message : 'Unknown error';
            setError(message);
            throw err;
        } finally {
            setLoading(false);
        }
    }, []);

    const get = useCallback(<T>(url: string): Promise<T> =>
        request<T>(url), [request]);

    const post = useCallback(<T, D>(url: string, data: D): Promise<T> =>
        request<T>(url, {
            method: 'POST',
            body: JSON.stringify(data)
        }), [request]);

    const put = useCallback(<T, D>(url: string, data: D): Promise<T> =>
        request<T>(url, {
            method: 'PUT',
            body: JSON.stringify(data)
        }), [request]);

    const del = useCallback(<T>(url: string): Promise<T> =>
        request<T>(url, { method: 'DELETE' }), [request]);

    return { get, post, put, del, loading, error };
}

// Usage
interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

function TodoApp(): JSX.Element {
    const { get, post, del, loading, error } = useApi();
    const [todos, setTodos] = useState<Todo[]>([]);

    useEffect(() => {
        loadTodos();
    }, []);

    const loadTodos = async (): Promise<void> => {
        const data = await get<Todo[]>('/api/todos');
        setTodos(data);
    };

    const addTodo = async (text: string): Promise<void> => {
        const newTodo = await post<Todo, { text: string }>('/api/todos', { text });
        setTodos((prev: Todo[]) => [...prev, newTodo]);
    };

    const deleteTodo = async (id: number): Promise<void> => {
        await del<void>(`/api/todos/${id}`);
        setTodos((prev: Todo[]) => prev.filter((t: Todo) => t.id !== id));
    };

    return (
        <div>
            {loading && <p>Loading...</p>}
            {error && <p>Error: {error}</p>}
            {/* Todo list UI */}
        </div>
    );
}

POST, PUT, DELETE Requests

jsx
function UserForm({ userId }) {
    const [formData, setFormData] = useState({
        name: '',
        email: ''
    });
    const [submitting, setSubmitting] = useState(false);
    const [error, setError] = useState(null);

    // Create user
    const createUser = async () => {
        setSubmitting(true);
        try {
            const response = await fetch('/api/users', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(formData)
            });

            if (!response.ok) throw new Error('Failed to create user');

            const newUser = await response.json();
            console.log('Created:', newUser);
        } catch (err) {
            setError(err.message);
        } finally {
            setSubmitting(false);
        }
    };

    // Update user
    const updateUser = async () => {
        setSubmitting(true);
        try {
            const response = await fetch(`/api/users/${userId}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(formData)
            });

            if (!response.ok) throw new Error('Failed to update user');

            const updatedUser = await response.json();
            console.log('Updated:', updatedUser);
        } catch (err) {
            setError(err.message);
        } finally {
            setSubmitting(false);
        }
    };

    // Delete user
    const deleteUser = async () => {
        if (!confirm('Are you sure?')) return;

        setSubmitting(true);
        try {
            const response = await fetch(`/api/users/${userId}`, {
                method: 'DELETE'
            });

            if (!response.ok) throw new Error('Failed to delete user');

            console.log('Deleted user:', userId);
        } catch (err) {
            setError(err.message);
        } finally {
            setSubmitting(false);
        }
    };

    return (
        <form onSubmit={(e) => {
            e.preventDefault();
            userId ? updateUser() : createUser();
        }}>
            <input
                value={formData.name}
                onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
                placeholder="Name"
            />
            <input
                value={formData.email}
                onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
                placeholder="Email"
            />
            <button type="submit" disabled={submitting}>
                {submitting ? 'Saving...' : userId ? 'Update' : 'Create'}
            </button>
            {userId && (
                <button type="button" onClick={deleteUser} disabled={submitting}>
                    Delete
                </button>
            )}
            {error && <p className="error">{error}</p>}
        </form>
    );
}
tsx
import { useState, FormEvent, ChangeEvent } from 'react';

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

interface User {
    id: number;
    name: string;
    email: string;
}

interface UserFormProps {
    userId?: number;
}

function UserForm({ userId }: UserFormProps): JSX.Element {
    const [formData, setFormData] = useState<UserFormData>({
        name: '',
        email: ''
    });
    const [submitting, setSubmitting] = useState<boolean>(false);
    const [error, setError] = useState<string | null>(null);

    // Create user
    const createUser = async (): Promise<void> => {
        setSubmitting(true);
        try {
            const response: Response = await fetch('/api/users', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(formData)
            });

            if (!response.ok) throw new Error('Failed to create user');

            const newUser: User = await response.json();
            console.log('Created:', newUser);
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Unknown error');
        } finally {
            setSubmitting(false);
        }
    };

    // Update user
    const updateUser = async (): Promise<void> => {
        setSubmitting(true);
        try {
            const response: Response = await fetch(`/api/users/${userId}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(formData)
            });

            if (!response.ok) throw new Error('Failed to update user');

            const updatedUser: User = await response.json();
            console.log('Updated:', updatedUser);
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Unknown error');
        } finally {
            setSubmitting(false);
        }
    };

    // Delete user
    const deleteUser = async (): Promise<void> => {
        if (!confirm('Are you sure?')) return;

        setSubmitting(true);
        try {
            const response: Response = await fetch(`/api/users/${userId}`, {
                method: 'DELETE'
            });

            if (!response.ok) throw new Error('Failed to delete user');

            console.log('Deleted user:', userId);
        } catch (err) {
            setError(err instanceof Error ? err.message : 'Unknown error');
        } finally {
            setSubmitting(false);
        }
    };

    const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
        e.preventDefault();
        userId ? updateUser() : createUser();
    };

    const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
        const { name, value } = e.target;
        setFormData((prev: UserFormData) => ({ ...prev, [name]: value }));
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                name="name"
                value={formData.name}
                onChange={handleChange}
                placeholder="Name"
            />
            <input
                name="email"
                value={formData.email}
                onChange={handleChange}
                placeholder="Email"
            />
            <button type="submit" disabled={submitting}>
                {submitting ? 'Saving...' : userId ? 'Update' : 'Create'}
            </button>
            {userId && (
                <button type="button" onClick={deleteUser} disabled={submitting}>
                    Delete
                </button>
            )}
            {error && <p className="error">{error}</p>}
        </form>
    );
}

Pagination

jsx
function PaginatedList() {
    const [items, setItems] = useState([]);
    const [page, setPage] = useState(1);
    const [totalPages, setTotalPages] = useState(1);
    const [loading, setLoading] = useState(true);
    const pageSize = 10;

    useEffect(() => {
        const fetchPage = async () => {
            setLoading(true);
            try {
                const response = await fetch(
                    `/api/items?page=${page}&limit=${pageSize}`
                );
                const data = await response.json();

                setItems(data.items);
                setTotalPages(Math.ceil(data.total / pageSize));
            } catch (err) {
                console.error('Fetch error:', err);
            } finally {
                setLoading(false);
            }
        };

        fetchPage();
    }, [page]);

    return (
        <div>
            {loading ? (
                <p>Loading...</p>
            ) : (
                <ul>
                    {items.map(item => (
                        <li key={item.id}>{item.name}</li>
                    ))}
                </ul>
            )}

            <div className="pagination">
                <button
                    onClick={() => setPage(p => Math.max(1, p - 1))}
                    disabled={page === 1}
                >
                    Previous
                </button>

                <span>Page {page} of {totalPages}</span>

                <button
                    onClick={() => setPage(p => Math.min(totalPages, p + 1))}
                    disabled={page === totalPages}
                >
                    Next
                </button>
            </div>
        </div>
    );
}
tsx
import { useState, useEffect } from 'react';

interface Item {
    id: number;
    name: string;
}

interface PaginatedResponse {
    items: Item[];
    total: number;
}

function PaginatedList(): JSX.Element {
    const [items, setItems] = useState<Item[]>([]);
    const [page, setPage] = useState<number>(1);
    const [totalPages, setTotalPages] = useState<number>(1);
    const [loading, setLoading] = useState<boolean>(true);
    const pageSize: number = 10;

    useEffect(() => {
        const fetchPage = async (): Promise<void> => {
            setLoading(true);
            try {
                const response: Response = await fetch(
                    `/api/items?page=${page}&limit=${pageSize}`
                );
                const data: PaginatedResponse = await response.json();

                setItems(data.items);
                setTotalPages(Math.ceil(data.total / pageSize));
            } catch (err) {
                console.error('Fetch error:', err);
            } finally {
                setLoading(false);
            }
        };

        fetchPage();
    }, [page]);

    return (
        <div>
            {loading ? (
                <p>Loading...</p>
            ) : (
                <ul>
                    {items.map((item: Item) => (
                        <li key={item.id}>{item.name}</li>
                    ))}
                </ul>
            )}

            <div className="pagination">
                <button
                    onClick={() => setPage((p: number) => Math.max(1, p - 1))}
                    disabled={page === 1}
                >
                    Previous
                </button>

                <span>Page {page} of {totalPages}</span>

                <button
                    onClick={() => setPage((p: number) => Math.min(totalPages, p + 1))}
                    disabled={page === totalPages}
                >
                    Next
                </button>
            </div>
        </div>
    );
}

Infinite Scroll

jsx
import { useState, useEffect, useRef, useCallback } from 'react';

function InfiniteList() {
    const [items, setItems] = useState([]);
    const [page, setPage] = useState(1);
    const [hasMore, setHasMore] = useState(true);
    const [loading, setLoading] = useState(false);
    const observer = useRef();

    // Last element ref callback
    const lastItemRef = useCallback(node => {
        if (loading) return;

        if (observer.current) observer.current.disconnect();

        observer.current = new IntersectionObserver(entries => {
            if (entries[0].isIntersecting && hasMore) {
                setPage(prev => prev + 1);
            }
        });

        if (node) observer.current.observe(node);
    }, [loading, hasMore]);

    useEffect(() => {
        const fetchItems = async () => {
            setLoading(true);
            try {
                const response = await fetch(`/api/items?page=${page}&limit=20`);
                const data = await response.json();

                setItems(prev => [...prev, ...data.items]);
                setHasMore(data.items.length === 20);
            } catch (err) {
                console.error('Error:', err);
            } finally {
                setLoading(false);
            }
        };

        fetchItems();
    }, [page]);

    return (
        <div className="infinite-list">
            {items.map((item, index) => {
                if (index === items.length - 1) {
                    return (
                        <div key={item.id} ref={lastItemRef}>
                            {item.name}
                        </div>
                    );
                }
                return <div key={item.id}>{item.name}</div>;
            })}

            {loading && <p>Loading more...</p>}
            {!hasMore && <p>No more items</p>}
        </div>
    );
}
tsx
import { useState, useEffect, useRef, useCallback } from 'react';

interface Item {
    id: number;
    name: string;
}

interface FetchResponse {
    items: Item[];
}

function InfiniteList(): JSX.Element {
    const [items, setItems] = useState<Item[]>([]);
    const [page, setPage] = useState<number>(1);
    const [hasMore, setHasMore] = useState<boolean>(true);
    const [loading, setLoading] = useState<boolean>(false);
    const observer = useRef<IntersectionObserver | null>(null);

    // Last element ref callback
    const lastItemRef = useCallback((node: HTMLDivElement | null) => {
        if (loading) return;

        if (observer.current) observer.current.disconnect();

        observer.current = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
            if (entries[0].isIntersecting && hasMore) {
                setPage((prev: number) => prev + 1);
            }
        });

        if (node) observer.current.observe(node);
    }, [loading, hasMore]);

    useEffect(() => {
        const fetchItems = async (): Promise<void> => {
            setLoading(true);
            try {
                const response: Response = await fetch(`/api/items?page=${page}&limit=20`);
                const data: FetchResponse = await response.json();

                setItems((prev: Item[]) => [...prev, ...data.items]);
                setHasMore(data.items.length === 20);
            } catch (err) {
                console.error('Error:', err);
            } finally {
                setLoading(false);
            }
        };

        fetchItems();
    }, [page]);

    return (
        <div className="infinite-list">
            {items.map((item: Item, index: number) => {
                if (index === items.length - 1) {
                    return (
                        <div key={item.id} ref={lastItemRef}>
                            {item.name}
                        </div>
                    );
                }
                return <div key={item.id}>{item.name}</div>;
            })}

            {loading && <p>Loading more...</p>}
            {!hasMore && <p>No more items</p>}
        </div>
    );
}

Caching with useMemo

jsx
function CachedFetch() {
    const [cache, setCache] = useState({});
    const [data, setData] = useState(null);
    const [url, setUrl] = useState('/api/default');

    useEffect(() => {
        const fetchData = async () => {
            // Check cache first
            if (cache[url]) {
                setData(cache[url]);
                return;
            }

            const response = await fetch(url);
            const json = await response.json();

            // Update cache
            setCache(prev => ({ ...prev, [url]: json }));
            setData(json);
        };

        fetchData();
    }, [url, cache]);

    return (
        <div>
            <button onClick={() => setUrl('/api/users')}>Users</button>
            <button onClick={() => setUrl('/api/posts')}>Posts</button>
            {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
        </div>
    );
}
tsx
import { useState, useEffect } from 'react';

type CacheData = Record<string, unknown>;

function CachedFetch(): JSX.Element {
    const [cache, setCache] = useState<CacheData>({});
    const [data, setData] = useState<unknown>(null);
    const [url, setUrl] = useState<string>('/api/default');

    useEffect(() => {
        const fetchData = async (): Promise<void> => {
            // Check cache first
            if (cache[url]) {
                setData(cache[url]);
                return;
            }

            const response: Response = await fetch(url);
            const json: unknown = await response.json();

            // Update cache
            setCache((prev: CacheData) => ({ ...prev, [url]: json }));
            setData(json);
        };

        fetchData();
    }, [url, cache]);

    return (
        <div>
            <button onClick={() => setUrl('/api/users')}>Users</button>
            <button onClick={() => setUrl('/api/posts')}>Posts</button>
            {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
        </div>
    );
}

Practical Example: Product Store

jsx
// hooks/useProducts.js
function useProducts() {
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    const [filters, setFilters] = useState({
        category: '',
        search: '',
        sort: 'name',
        page: 1
    });

    const buildUrl = useCallback(() => {
        const params = new URLSearchParams();
        if (filters.category) params.set('category', filters.category);
        if (filters.search) params.set('q', filters.search);
        params.set('sort', filters.sort);
        params.set('page', filters.page);
        return `/api/products?${params}`;
    }, [filters]);

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

        const fetchProducts = async () => {
            setLoading(true);
            try {
                const response = await fetch(buildUrl(), {
                    signal: controller.signal
                });

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

                const data = await response.json();
                setProducts(data.products);
                setError(null);
            } catch (err) {
                if (err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setLoading(false);
            }
        };

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

    const updateFilters = useCallback((updates) => {
        setFilters(prev => ({ ...prev, ...updates, page: 1 }));
    }, []);

    const nextPage = useCallback(() => {
        setFilters(prev => ({ ...prev, page: prev.page + 1 }));
    }, []);

    return {
        products,
        loading,
        error,
        filters,
        updateFilters,
        nextPage
    };
}

// components/ProductStore.jsx
function ProductStore() {
    const {
        products,
        loading,
        error,
        filters,
        updateFilters
    } = useProducts();

    return (
        <div className="store">
            <aside className="filters">
                <input
                    type="search"
                    placeholder="Search products..."
                    value={filters.search}
                    onChange={(e) => updateFilters({ search: e.target.value })}
                />

                <select
                    value={filters.category}
                    onChange={(e) => updateFilters({ category: e.target.value })}
                >
                    <option value="">All Categories</option>
                    <option value="electronics">Electronics</option>
                    <option value="clothing">Clothing</option>
                    <option value="books">Books</option>
                </select>

                <select
                    value={filters.sort}
                    onChange={(e) => updateFilters({ sort: e.target.value })}
                >
                    <option value="name">Sort by Name</option>
                    <option value="price-asc">Price: Low to High</option>
                    <option value="price-desc">Price: High to Low</option>
                </select>
            </aside>

            <main className="products">
                {loading && <div className="loading">Loading...</div>}

                {error && (
                    <div className="error">
                        <p>{error}</p>
                        <button onClick={() => updateFilters({})}>Retry</button>
                    </div>
                )}

                {!loading && !error && products.length === 0 && (
                    <p>No products found</p>
                )}

                <div className="product-grid">
                    {products.map(product => (
                        <ProductCard key={product.id} product={product} />
                    ))}
                </div>
            </main>
        </div>
    );
}
tsx
import { useState, useEffect, useCallback, ChangeEvent } from 'react';

// Types
interface Product {
    id: number;
    name: string;
    price: number;
    category: string;
}

interface ProductFilters {
    category: string;
    search: string;
    sort: string;
    page: number;
}

interface ProductsResponse {
    products: Product[];
    total: number;
}

interface UseProductsResult {
    products: Product[];
    loading: boolean;
    error: string | null;
    filters: ProductFilters;
    updateFilters: (updates: Partial<ProductFilters>) => void;
    nextPage: () => void;
}

// hooks/useProducts.ts
function useProducts(): UseProductsResult {
    const [products, setProducts] = useState<Product[]>([]);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);
    const [filters, setFilters] = useState<ProductFilters>({
        category: '',
        search: '',
        sort: 'name',
        page: 1
    });

    const buildUrl = useCallback((): string => {
        const params = new URLSearchParams();
        if (filters.category) params.set('category', filters.category);
        if (filters.search) params.set('q', filters.search);
        params.set('sort', filters.sort);
        params.set('page', filters.page.toString());
        return `/api/products?${params}`;
    }, [filters]);

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

        const fetchProducts = async (): Promise<void> => {
            setLoading(true);
            try {
                const response: Response = await fetch(buildUrl(), {
                    signal: controller.signal
                });

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

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

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

    const updateFilters = useCallback((updates: Partial<ProductFilters>): void => {
        setFilters((prev: ProductFilters) => ({ ...prev, ...updates, page: 1 }));
    }, []);

    const nextPage = useCallback((): void => {
        setFilters((prev: ProductFilters) => ({ ...prev, page: prev.page + 1 }));
    }, []);

    return {
        products,
        loading,
        error,
        filters,
        updateFilters,
        nextPage
    };
}

// components/ProductStore.tsx
interface ProductCardProps {
    product: Product;
}

function ProductCard({ product }: ProductCardProps): JSX.Element {
    return (
        <div className="product-card">
            <h3>{product.name}</h3>
            <p>${product.price}</p>
        </div>
    );
}

function ProductStore(): JSX.Element {
    const {
        products,
        loading,
        error,
        filters,
        updateFilters
    } = useProducts();

    return (
        <div className="store">
            <aside className="filters">
                <input
                    type="search"
                    placeholder="Search products..."
                    value={filters.search}
                    onChange={(e: ChangeEvent<HTMLInputElement>) =>
                        updateFilters({ search: e.target.value })
                    }
                />

                <select
                    value={filters.category}
                    onChange={(e: ChangeEvent<HTMLSelectElement>) =>
                        updateFilters({ category: e.target.value })
                    }
                >
                    <option value="">All Categories</option>
                    <option value="electronics">Electronics</option>
                    <option value="clothing">Clothing</option>
                    <option value="books">Books</option>
                </select>

                <select
                    value={filters.sort}
                    onChange={(e: ChangeEvent<HTMLSelectElement>) =>
                        updateFilters({ sort: e.target.value })
                    }
                >
                    <option value="name">Sort by Name</option>
                    <option value="price-asc">Price: Low to High</option>
                    <option value="price-desc">Price: High to Low</option>
                </select>
            </aside>

            <main className="products">
                {loading && <div className="loading">Loading...</div>}

                {error && (
                    <div className="error">
                        <p>{error}</p>
                        <button onClick={() => updateFilters({})}>Retry</button>
                    </div>
                )}

                {!loading && !error && products.length === 0 && (
                    <p>No products found</p>
                )}

                <div className="product-grid">
                    {products.map((product: Product) => (
                        <ProductCard key={product.id} product={product} />
                    ))}
                </div>
            </main>
        </div>
    );
}

Data Fetching Best Practices

PracticeDescription
Handle all statesLoading, error, empty, and success
Cancel requestsUse AbortController for cleanup
Avoid race conditionsCheck if component is mounted
Show loading indicatorsProvide feedback during fetches
Cache when appropriateAvoid redundant requests
Handle errors gracefullyShow retry options
Use proper HTTP methodsGET for reading, POST for creating

What's Next?

In the next chapter, we'll explore Advanced Patterns - Context API, performance optimization, code splitting, and more.


Next: Advanced Patterns →