Skip to main content

๐ŸŒ Data Fetching Basics

Most React applications need to fetch data from APIs. Whether you're building a weather app, a social media feed, or an e-commerce site, you'll need to request data from servers and display it to users. In this lesson, you'll learn how to fetch data using the Fetch API, handle loading and error states gracefully, type your API responses with TypeScript, and build production-ready data-fetching components. By the end, you'll be able to integrate any REST API into your React applications! ๐Ÿš€

๐ŸŽฏ Learning Objectives

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

  • Understand the Fetch API and how it works
  • Fetch data from REST APIs using useEffect
  • Handle loading states while data fetches
  • Handle and display error states properly
  • Type API responses with TypeScript interfaces
  • Cancel fetch requests to prevent memory leaks
  • Use async/await syntax in effects
  • Build reusable data-fetching components
  • Implement common patterns (refetch, pagination)
  • Debug network requests effectively

Estimated Time: 75-90 minutes

Project: Build a user profile viewer and a posts list with real API data

๐Ÿ“‘ In This Lesson

๐ŸŒ Understanding the Fetch API

Before we integrate fetch into React, let's understand how the Fetch API works in JavaScript.

๐Ÿ“– Definition

Fetch API: A modern JavaScript interface for making HTTP requests to servers. It returns Promises and provides a cleaner alternative to XMLHttpRequest.

Basic Fetch Syntax

Simple GET Request

// Basic fetch - returns a Promise
fetch('https://api.example.com/data')
    .then(response => response.json()) // Parse JSON
    .then(data => console.log(data))   // Use data
    .catch(error => console.error(error)); // Handle errors

// With async/await (cleaner)
async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

How Fetch Works

sequenceDiagram participant C as ๐Ÿ’ป Your Code participant F as ๐ŸŒ Fetch API participant S as ๐Ÿ–ฅ๏ธ Server participant U as ๐Ÿ‘ค User C->>F: fetch(url) F->>S: HTTP Request S->>F: HTTP Response F->>C: Response Object C->>C: response.json() C->>C: Parse JSON C->>U: Display Data

๐ŸŽฎ Interactive: Watch a Fetch Request

Click the button to simulate a fetch request and watch the data flow:

Request:
GET /users/1
Response:
Waiting...

The Response Object

Understanding the Response

const response = await fetch('https://api.example.com/data');

// Response properties
console.log(response.ok);        // true if status 200-299
console.log(response.status);    // HTTP status code (200, 404, etc.)
console.log(response.statusText); // Status text ("OK", "Not Found")
console.log(response.headers);   // Response headers

// Getting data from response
const json = await response.json();        // Parse as JSON
const text = await response.text();        // Get as text
const blob = await response.blob();        // Get as binary data
const formData = await response.formData(); // Get as form data

HTTP Methods

Different Request Types

// GET (default) - Retrieve data
fetch('https://api.example.com/users');

// POST - Create data
fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});

// PUT - Update data (replace entire resource)
fetch('https://api.example.com/users/1', {
    method: 'PUT',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ name: 'Alice Smith', email: 'alice@example.com' })
});

// PATCH - Update data (modify specific fields)
fetch('https://api.example.com/users/1', {
    method: 'PATCH',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ name: 'Alice Smith' })
});

// DELETE - Remove data
fetch('https://api.example.com/users/1', {
    method: 'DELETE'
});

Request Options

Configuring Fetch Requests

fetch('https://api.example.com/data', {
    method: 'GET',              // HTTP method
    headers: {                  // Request headers
        'Content-Type': 'application/json',
        'Authorization': 'Bearer token123'
    },
    body: JSON.stringify(data), // Request body (for POST/PUT/PATCH)
    mode: 'cors',               // CORS mode
    credentials: 'include',     // Send cookies
    cache: 'no-cache',          // Cache mode
    redirect: 'follow',         // Redirect behavior
    signal: abortController.signal // For cancellation
});

Public APIs for Practice

๐Ÿ’ก Free APIs to Use

API URL Description
JSONPlaceholder jsonplaceholder.typicode.com Fake REST API for testing
PokรฉAPI pokeapi.co Pokรฉmon data
OpenWeather openweathermap.org Weather data (API key required)
Dog API dog.ceo/dog-api Random dog images
Rick and Morty rickandmortyapi.com TV show data

We'll use JSONPlaceholder for examples - it's perfect for learning!

โšก Fetch with useEffect

Now let's integrate fetch into React components using useEffect. This is where data fetching happens in React!

Basic Pattern

Fetching Data on Mount

import { useState, useEffect } from 'react';

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

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    
    useEffect(() => {
        // Fetch user data when component mounts
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => setUser(data));
    }, []); // Empty array = run once on mount
    
    if (!user) {
        return <div>Loading...</div>;
    }
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
        </div>
    );
};

Why useEffect?

๐Ÿ’ก Why Not Fetch During Render?

// โŒ WRONG: Fetching during render
const BadComponent: React.FC = () => {
    const [data, setData] = useState(null);
    
    // This runs on EVERY render!
    fetch('/api/data')
        .then(res => res.json())
        .then(setData); // Causes re-render
    // Re-render causes fetch again... infinite loop! ๐Ÿ’ฅ
    
    return <div>{data}</div>;
};

// โœ… CORRECT: Fetching in useEffect
const GoodComponent: React.FC = () => {
    const [data, setData] = useState(null);
    
    useEffect(() => {
        fetch('/api/data')
            .then(res => res.json())
            .then(setData);
    }, []); // Runs once on mount
    
    return <div>{data}</div>;
};

Fetching Based on Props

Re-fetch When Data Changes

interface UserProfileProps {
    userId: number;
}

const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
    const [user, setUser] = useState<User | null>(null);
    
    useEffect(() => {
        // Fetch different user when userId changes
        fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
            .then(response => response.json())
            .then(data => setUser(data));
    }, [userId]); // Re-run when userId changes
    
    if (!user) {
        return <div>Loading...</div>;
    }
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
        </div>
    );
};

// Usage
<UserProfile userId={1} /> // Fetches user 1
<UserProfile userId={2} /> // Fetches user 2

Multiple Fetches

Fetching Multiple Resources

const Dashboard: React.FC = () => {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);
    const [todos, setTodos] = useState([]);
    
    useEffect(() => {
        // Fetch user
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(res => res.json())
            .then(setUser);
        
        // Fetch posts
        fetch('https://jsonplaceholder.typicode.com/posts?userId=1')
            .then(res => res.json())
            .then(setPosts);
        
        // Fetch todos
        fetch('https://jsonplaceholder.typicode.com/todos?userId=1')
            .then(res => res.json())
            .then(setTodos);
    }, []);
    
    return (
        <div>
            <h1>Dashboard</h1>
            {/* Display data */}
        </div>
    );
};

Sequential Fetches

Fetching Data That Depends on Other Data

const UserWithPosts: React.FC<{ userId: number }> = ({ userId }) => {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);
    
    // First effect: Fetch user
    useEffect(() => {
        fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
            .then(res => res.json())
            .then(setUser);
    }, [userId]);
    
    // Second effect: Fetch posts after we have user
    useEffect(() => {
        if (!user) return; // Wait for user to load
        
        fetch(`https://jsonplaceholder.typicode.com/posts?userId=${user.id}`)
            .then(res => res.json())
            .then(setPosts);
    }, [user]); // Run when user changes
    
    if (!user) return <div>Loading user...</div>;
    if (posts.length === 0) return <div>Loading posts...</div>;
    
    return (
        <div>
            <h2>{user.name}'s Posts</h2>
            {posts.map(post => (
                <article key={post.id}>
                    <h3>{post.title}</h3>
                    <p>{post.body}</p>
                </article>
            ))}
        </div>
    );
};

โณ Loading States

Good UX means showing users what's happening. Loading states tell users "we're working on itโ€”please wait!"

Basic Loading State

Simple Loading Indicator

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    
    useEffect(() => {
        setIsLoading(true); // Start loading
        
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => {
                setUser(data);
                setIsLoading(false); // Done loading
            });
    }, []);
    
    if (isLoading) {
        return <div>Loading...</div>;
    }
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <p>{user?.email}</p>
        </div>
    );
};

Loading State Flow

stateDiagram-v2 [*] --> Loading: Component Mounts Loading --> Success: Data Received Loading --> Error: Request Failed Success --> [*] Error --> [*]

๐ŸŽฎ Interactive: Loading State Machine

Click buttons to simulate different fetch outcomes and see how states change:

Click "Start Fetch" to begin

State: { isLoading: false, error: null, data: null }

Better Loading UI

Spinner Component

const LoadingSpinner: React.FC = () => (
    <div className="loading-spinner">
        <div className="spinner"></div>
        <p>Loading...</p>
    </div>
);

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => {
                setUser(data);
                setIsLoading(false);
            });
    }, []);
    
    if (isLoading) {
        return <LoadingSpinner />;
    }
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <p>{user?.email}</p>
        </div>
    );
};

Skeleton Screens

Show Content Structure While Loading

const UserSkeleton: React.FC = () => (
    <div className="user-skeleton">
        <div className="skeleton skeleton-avatar"></div>
        <div className="skeleton skeleton-title"></div>
        <div className="skeleton skeleton-text"></div>
        <div className="skeleton skeleton-text"></div>
    </div>
);

// CSS for skeleton
/*
.skeleton {
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    border-radius: 4px;
}

@keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}
*/

Inline Loading State

Show Loading Without Replacing Content

const PostsList: React.FC = () => {
    const [posts, setPosts] = useState([]);
    const [isLoading, setIsLoading] = useState(false);
    
    const loadMore = () => {
        setIsLoading(true);
        
        fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')
            .then(res => res.json())
            .then(newPosts => {
                setPosts([...posts, ...newPosts]);
                setIsLoading(false);
            });
    };
    
    return (
        <div>
            <h2>Posts</h2>
            {posts.map(post => (
                <article key={post.id}>
                    <h3>{post.title}</h3>
                    <p>{post.body}</p>
                </article>
            ))}
            
            {/* Show button or loading spinner */}
            {isLoading ? (
                <div>Loading more...</div>
            ) : (
                <button onClick={loadMore}>Load More</button>
            )}
        </div>
    );
};

โœ… Loading State Best Practices

  • Always show loading state: Never leave users wondering
  • Be specific: "Loading posts..." is better than "Loading..."
  • Use skeletons for complex UI: Shows structure, feels faster
  • Show progress if possible: Progress bars when applicable
  • Keep it fast: If loading takes >3 seconds, explain why

โŒ Error Handling

Networks fail. APIs go down. Users lose connection. Proper error handling is essential for production apps!

Basic Error Handling

Catching Fetch Errors

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => {
                setUser(data);
                setIsLoading(false);
            })
            .catch(err => {
                setError(err.message);
                setIsLoading(false);
            });
    }, []);
    
    if (isLoading) {
        return <div>Loading...</div>;
    }
    
    if (error) {
        return <div>Error: {error}</div>;
    }
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <p>{user?.email}</p>
        </div>
    );
};

HTTP Status Error Handling

Check Response Status

const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
            .then(response => {
                // Check if response is OK (status 200-299)
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                setUser(data);
                setIsLoading(false);
            })
            .catch(err => {
                setError(err.message);
                setIsLoading(false);
            });
    }, [userId]);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <p>{user?.email}</p>
        </div>
    );
};

Error Types

Different Error Scenarios

const fetchWithErrorHandling = async (url: string) => {
    try {
        const response = await fetch(url);
        
        // Network error (offline, DNS failure, etc.)
        if (!response.ok) {
            // HTTP error status codes
            switch (response.status) {
                case 404:
                    throw new Error('Resource not found');
                case 401:
                    throw new Error('Unauthorized - please login');
                case 403:
                    throw new Error('Forbidden - no access');
                case 500:
                    throw new Error('Server error - try again later');
                default:
                    throw new Error(`Error: ${response.status}`);
            }
        }
        
        const data = await response.json();
        return data;
        
    } catch (error) {
        // Network failure (no internet, CORS, etc.)
        if (error instanceof TypeError) {
            throw new Error('Network error - check your connection');
        }
        
        // JSON parse error
        if (error instanceof SyntaxError) {
            throw new Error('Invalid response format');
        }
        
        // Re-throw other errors
        throw error;
    }
};

Better Error UI

User-Friendly Error Display

interface ErrorDisplayProps {
    error: string;
    onRetry?: () => void;
}

const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry }) => (
    <div className="error-display">
        <div className="error-icon">โŒ</div>
        <h3>Oops! Something went wrong</h3>
        <p>{error}</p>
        {onRetry && (
            <button onClick={onRetry}>Try Again</button>
        )}
    </div>
);

// Usage
const UserProfile: React.FC = () => {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);
    
    const fetchUser = () => {
        setIsLoading(true);
        setError(null);
        
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => {
                if (!response.ok) throw new Error('Failed to load user');
                return response.json();
            })
            .then(setUser)
            .catch(err => setError(err.message))
            .finally(() => setIsLoading(false));
    };
    
    useEffect(() => {
        fetchUser();
    }, []);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <ErrorDisplay error={error} onRetry={fetchUser} />;
    
    return <div>{user?.name}</div>;
};

โœ… Error Handling Best Practices

  • Always handle errors: Never leave users with broken UI
  • Check response.ok: Fetch doesn't throw on HTTP errors
  • Provide context: Tell users what went wrong
  • Offer recovery: Retry buttons, alternative actions
  • Log errors: Send to error tracking service in production
  • Don't expose technical details: "Server error" not "500 Internal"

๐Ÿ“ Typing API Responses

TypeScript makes APIs safer and easier to work with. Let's learn how to properly type our API responses!

Defining Response Types

Create Interfaces for API Data

// types/user.ts
export interface User {
    id: number;
    name: string;
    username: string;
    email: string;
    address: Address;
    phone: string;
    website: string;
    company: Company;
}

export interface Address {
    street: string;
    suite: string;
    city: string;
    zipcode: string;
    geo: Geo;
}

export interface Geo {
    lat: string;
    lng: string;
}

export interface Company {
    name: string;
    catchPhrase: string;
    bs: string;
}

// types/post.ts
export interface Post {
    userId: number;
    id: number;
    title: string;
    body: string;
}

// types/comment.ts
export interface Comment {
    postId: number;
    id: number;
    name: string;
    email: string;
    body: string;
}

Using Types with useState

Type State Correctly

import { useState, useEffect } from 'react';
import { User } from './types/user';

const UserProfile: React.FC = () => {
    // Type as User | null (could be null while loading)
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState<boolean>(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then((data: User) => { // Type the data
                setUser(data);
                setIsLoading(false);
            })
            .catch((err: Error) => {
                setError(err.message);
                setIsLoading(false);
            });
    }, []);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!user) return <div>No user found</div>;
    
    // TypeScript knows user is User here!
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
            <p>{user.address.city}</p> {/* TypeScript autocomplete! */}
        </div>
    );
};

Typing Fetch Functions

Generic Fetch Function

// utils/api.ts
export async function fetchData<T>(url: string): Promise<T> {
    const response = await fetch(url);
    
    if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data as T;
}

// Usage
import { fetchData } from './utils/api';
import { User } from './types/user';

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    
    useEffect(() => {
        fetchData<User>('https://jsonplaceholder.typicode.com/users/1')
            .then(setUser)
            .catch(console.error);
    }, []);
    
    // user is typed as User!
};

Validating API Responses

Runtime Type Checking

// Type guards
function isUser(data: any): data is User {
    return (
        typeof data === 'object' &&
        data !== null &&
        typeof data.id === 'number' &&
        typeof data.name === 'string' &&
        typeof data.email === 'string'
    );
}

// Usage
const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => {
                if (isUser(data)) {
                    setUser(data);
                } else {
                    throw new Error('Invalid user data');
                }
            })
            .catch(err => setError(err.message));
    }, []);
    
    // ...
};

Using Zod for Validation

๐Ÿ’ก Better Validation with Zod

// Install: npm install zod

import { z } from 'zod';

// Define schema
const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email(),
    username: z.string(),
    phone: z.string(),
    website: z.string()
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Validate API response
const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/users/1')
            .then(response => response.json())
            .then(data => {
                // Validate and parse
                const validatedUser = UserSchema.parse(data);
                setUser(validatedUser);
            })
            .catch(err => setError(err.message));
    }, []);
    
    // ...
};

โšก Async/Await in Effects

Async/await makes asynchronous code much more readable. Let's learn how to use it properly with useEffect!

The Problem: Can't Make Effect Async

โš ๏ธ This Doesn't Work

// โŒ WRONG: Effect function can't be async
useEffect(async () => {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    setData(data);
}, []);

// TypeScript Error: 
// Effect callbacks are synchronous to prevent race conditions.
// Put the async function inside the effect.

Solution 1: Define Async Function Inside

โœ… Correct Pattern

const UserProfile: React.FC = () => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        // Define async function inside effect
        const fetchUser = async () => {
            try {
                setIsLoading(true);
                
                const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const data = await response.json();
                setUser(data);
                
            } catch (err) {
                setError(err instanceof Error ? err.message : 'An error occurred');
            } finally {
                setIsLoading(false);
            }
        };
        
        // Call the async function
        fetchUser();
    }, []);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!user) return <div>No user found</div>;
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
        </div>
    );
};

Solution 2: Immediately Invoked Async Function

IIFE Pattern

useEffect(() => {
    // Immediately invoked async function
    (async () => {
        try {
            const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
            const data = await response.json();
            setUser(data);
        } catch (err) {
            setError(err.message);
        }
    })();
}, []);

Multiple Async Operations

Sequential Fetches

useEffect(() => {
    const fetchData = async () => {
        try {
            setIsLoading(true);
            
            // Fetch user first
            const userResponse = await fetch('https://jsonplaceholder.typicode.com/users/1');
            const userData = await userResponse.json();
            setUser(userData);
            
            // Then fetch their posts
            const postsResponse = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userData.id}`);
            const postsData = await postsResponse.json();
            setPosts(postsData);
            
        } catch (err) {
            setError(err.message);
        } finally {
            setIsLoading(false);
        }
    };
    
    fetchData();
}, []);

Parallel Fetches with Promise.all

Fetch Multiple Resources Simultaneously

useEffect(() => {
    const fetchAllData = async () => {
        try {
            setIsLoading(true);
            
            // Fetch all at once
            const [userResponse, postsResponse, todosResponse] = await Promise.all([
                fetch('https://jsonplaceholder.typicode.com/users/1'),
                fetch('https://jsonplaceholder.typicode.com/posts?userId=1'),
                fetch('https://jsonplaceholder.typicode.com/todos?userId=1')
            ]);
            
            // Parse all responses
            const [userData, postsData, todosData] = await Promise.all([
                userResponse.json(),
                postsResponse.json(),
                todosResponse.json()
            ]);
            
            setUser(userData);
            setPosts(postsData);
            setTodos(todosData);
            
        } catch (err) {
            setError(err.message);
        } finally {
            setIsLoading(false);
        }
    };
    
    fetchAllData();
}, []);

// Promise.all is faster because requests happen in parallel!
// Sequential: 300ms + 300ms + 300ms = 900ms
// Parallel: max(300ms, 300ms, 300ms) = 300ms

Error Handling with Try-Catch

Comprehensive Error Handling

useEffect(() => {
    const fetchUser = async () => {
        try {
            setIsLoading(true);
            setError(null);
            
            const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
            
            // Check response status
            if (!response.ok) {
                throw new Error(`Failed to fetch user: ${response.status}`);
            }
            
            const data = await response.json();
            
            // Validate data
            if (!data.id || !data.name) {
                throw new Error('Invalid user data received');
            }
            
            setUser(data);
            
        } catch (err) {
            // Handle different error types
            if (err instanceof TypeError) {
                setError('Network error - please check your connection');
            } else if (err instanceof SyntaxError) {
                setError('Invalid response format');
            } else if (err instanceof Error) {
                setError(err.message);
            } else {
                setError('An unknown error occurred');
            }
        } finally {
            // Always runs, even if there's an error
            setIsLoading(false);
        }
    };
    
    fetchUser();
}, []);

โœ… Async/Await Best Practices

  • Define async function inside effect: Don't make effect itself async
  • Always use try-catch: Handle errors properly
  • Use finally: For cleanup like setting loading to false
  • Use Promise.all: For parallel requests
  • Check response.ok: Before parsing JSON
  • Type your data: Add type annotations to responses

๐Ÿ›‘ Canceling Requests

When components unmount or dependencies change, we need to cancel in-flight requests to prevent memory leaks and race conditions!

The Problem: Memory Leaks

โš ๏ธ Without Cancellation

// โŒ PROBLEM: Request completes after unmount
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
            .then(res => res.json())
            .then(setUser); // What if component unmounted?
    }, [userId]);
    
    return <div>{user?.name}</div>;
};

// Scenario:
// 1. userId = 1, fetch starts
// 2. userId changes to 2, new fetch starts
// 3. First fetch completes (slow network)
// 4. setUser called with old data!
// Result: Wrong user displayed! ๐Ÿ’ฅ

Solution: AbortController

Properly Canceling Requests

const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        // Create abort controller
        const controller = new AbortController();
        
        const fetchUser = async () => {
            try {
                setIsLoading(true);
                
                // Pass signal to fetch
                const response = await fetch(
                    `https://jsonplaceholder.typicode.com/users/${userId}`,
                    { signal: controller.signal }
                );
                
                if (!response.ok) {
                    throw new Error('Failed to fetch user');
                }
                
                const data = await response.json();
                setUser(data);
                
            } catch (err) {
                // Don't set error if request was aborted
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchUser();
        
        // Cleanup: Cancel request on unmount or userId change
        return () => {
            controller.abort();
        };
    }, [userId]);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!user) return <div>No user found</div>;
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
        </div>
    );
};

How AbortController Works

sequenceDiagram participant E as โšก Effect participant A as ๐Ÿ›‘ AbortController participant F as ๐ŸŒ Fetch participant S as ๐Ÿ–ฅ๏ธ Server E->>A: Create controller E->>F: fetch(url, {signal}) F->>S: HTTP Request Note over E,A: Component unmounts or deps change E->>A: controller.abort() A->>F: Cancel request F->>F: Throw AbortError Note over S: Response never processed

๐ŸŽฎ Interactive: Request Cancellation

See how AbortController prevents race conditions when requests are cancelled:

Ready to demonstrate request cancellation...

Using with Async Functions

Pattern with Try-Catch

useEffect(() => {
    const controller = new AbortController();
    
    const fetchData = async () => {
        try {
            const response = await fetch(url, {
                signal: controller.signal
            });
            
            const data = await response.json();
            setData(data);
            
        } catch (err) {
            // Check if error is from abort
            if (err instanceof Error) {
                if (err.name === 'AbortError') {
                    console.log('Fetch aborted');
                    return; // Don't set error state
                }
                setError(err.message);
            }
        }
    };
    
    fetchData();
    
    return () => {
        controller.abort();
    };
}, [url]);

Race Condition Prevention

Search Component Example

const Search: React.FC = () => {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState([]);
    const [isLoading, setIsLoading] = useState(false);
    
    useEffect(() => {
        if (!query) {
            setResults([]);
            return;
        }
        
        const controller = new AbortController();
        
        const searchAPI = async () => {
            try {
                setIsLoading(true);
                
                const response = await fetch(
                    `https://api.example.com/search?q=${query}`,
                    { signal: controller.signal }
                );
                
                const data = await response.json();
                setResults(data);
                
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    console.error(err);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        searchAPI();
        
        // Cancel when query changes
        return () => {
            controller.abort();
        };
    }, [query]);
    
    return (
        <div>
            <input
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
            />
            {isLoading && <div>Searching...</div>}
            <ul>
                {results.map(result => (
                    <li key={result.id}>{result.name}</li>
                ))}
            </ul>
        </div>
    );
};

Multiple Requests Cancellation

Cancel All Requests on Cleanup

useEffect(() => {
    const controller = new AbortController();
    
    const fetchAllData = async () => {
        try {
            // Use same signal for all requests
            const [usersRes, postsRes] = await Promise.all([
                fetch('https://jsonplaceholder.typicode.com/users', {
                    signal: controller.signal
                }),
                fetch('https://jsonplaceholder.typicode.com/posts', {
                    signal: controller.signal
                })
            ]);
            
            const [users, posts] = await Promise.all([
                usersRes.json(),
                postsRes.json()
            ]);
            
            setUsers(users);
            setPosts(posts);
            
        } catch (err) {
            if (err instanceof Error && err.name !== 'AbortError') {
                setError(err.message);
            }
        }
    };
    
    fetchAllData();
    
    // Aborts ALL requests using this controller
    return () => {
        controller.abort();
    };
}, []);

โœ… Request Cancellation Best Practices

  • Always use AbortController: For all fetch requests in effects
  • Check for AbortError: Don't treat it as a real error
  • One controller per effect: Create new controller in each effect run
  • Cancel in cleanup: Return cleanup function that calls abort()
  • Don't update state after abort: Check for AbortError first

๐ŸŽฏ Complete Data Fetching Pattern

Let's put everything together into a production-ready pattern that handles all edge cases!

The Complete Pattern

Full-Featured Data Fetching Component

import { useState, useEffect } from 'react';

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

interface UserProfileProps {
    userId: number;
}

const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
    // State
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        // Create abort controller
        const controller = new AbortController();
        
        const fetchUser = async () => {
            try {
                // Reset states
                setIsLoading(true);
                setError(null);
                
                // Fetch with cancellation support
                const response = await fetch(
                    `https://jsonplaceholder.typicode.com/users/${userId}`,
                    { signal: controller.signal }
                );
                
                // Check response status
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                // Parse JSON
                const data: User = await response.json();
                
                // Validate data
                if (!data.id || !data.name) {
                    throw new Error('Invalid user data');
                }
                
                // Update state
                setUser(data);
                
            } catch (err) {
                // Don't set error if aborted
                if (err instanceof Error && err.name === 'AbortError') {
                    console.log('Fetch aborted');
                    return;
                }
                
                // Handle different error types
                if (err instanceof TypeError) {
                    setError('Network error - check your connection');
                } else if (err instanceof Error) {
                    setError(err.message);
                } else {
                    setError('An unknown error occurred');
                }
            } finally {
                // Always set loading to false
                setIsLoading(false);
            }
        };
        
        fetchUser();
        
        // Cleanup: cancel request
        return () => {
            controller.abort();
        };
    }, [userId]); // Re-fetch when userId changes
    
    // Loading state
    if (isLoading) {
        return (
            <div className="loading">
                <div className="spinner"></div>
                <p>Loading user...</p>
            </div>
        );
    }
    
    // Error state
    if (error) {
        return (
            <div className="error">
                <h3>โŒ Error</h3>
                <p>{error}</p>
                <button onClick={() => window.location.reload()}>
                    Retry
                </button>
            </div>
        );
    }
    
    // No data state
    if (!user) {
        return (
            <div className="empty">
                <p>No user found</p>
            </div>
        );
    }
    
    // Success state
    return (
        <div className="user-profile">
            <h2>{user.name}</h2>
            <p>Username: {user.username}</p>
            <p>Email: {user.email}</p>
        </div>
    );
};

export default UserProfile;

Reusable Fetch Hook

Custom Hook for Data Fetching

// hooks/useFetch.ts
import { useState, useEffect } from 'react';

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

export function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    const [refetchTrigger, setRefetchTrigger] = useState(0);
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                setError(null);
                
                const response = await fetch(url, {
                    signal: controller.signal
                });
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const json = await response.json();
                setData(json);
                
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        return () => {
            controller.abort();
        };
    }, [url, refetchTrigger]);
    
    const refetch = () => {
        setRefetchTrigger(prev => prev + 1);
    };
    
    return { data, isLoading, error, refetch };
}

// Usage
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const { data: user, isLoading, error, refetch } = useFetch<User>(
        `https://jsonplaceholder.typicode.com/users/${userId}`
    );
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error} <button onClick={refetch}>Retry</button></div>;
    if (!user) return <div>No user</div>;
    
    return (
        <div>
            <h2>{user.name}</h2>
            <p>{user.email}</p>
            <button onClick={refetch}>Refresh</button>
        </div>
    );
};

State Machine Pattern

Better State Management

type FetchState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: string };

const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const [state, setState] = useState<FetchState<User>>({
        status: 'idle'
    });
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchUser = async () => {
            setState({ status: 'loading' });
            
            try {
                const response = await fetch(
                    `https://jsonplaceholder.typicode.com/users/${userId}`,
                    { signal: controller.signal }
                );
                
                if (!response.ok) throw new Error('Failed to fetch');
                
                const data = await response.json();
                setState({ status: 'success', data });
                
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setState({ status: 'error', error: err.message });
                }
            }
        };
        
        fetchUser();
        
        return () => controller.abort();
    }, [userId]);
    
    // Render based on status
    switch (state.status) {
        case 'idle':
        case 'loading':
            return <div>Loading...</div>;
        
        case 'error':
            return <div>Error: {state.error}</div>;
        
        case 'success':
            return (
                <div>
                    <h2>{state.data.name}</h2>
                    <p>{state.data.email}</p>
                </div>
            );
    }
};

๐ŸŽจ Common Patterns

Let's explore practical patterns you'll use frequently in real applications!

Pattern 1: Manual Refetch

Refresh Button

const UserProfile: React.FC = () => {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(false);
    const [refreshKey, setRefreshKey] = useState(0);
    
    useEffect(() => {
        const fetchUser = async () => {
            setIsLoading(true);
            const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
            const data = await response.json();
            setUser(data);
            setIsLoading(false);
        };
        
        fetchUser();
    }, [refreshKey]); // Re-run when refreshKey changes
    
    const handleRefresh = () => {
        setRefreshKey(prev => prev + 1);
    };
    
    return (
        <div>
            {isLoading ? (
                <div>Loading...</div>
            ) : (
                <>
                    <h2>{user?.name}</h2>
                    <button onClick={handleRefresh} disabled={isLoading}>
                        {isLoading ? 'Refreshing...' : 'Refresh'}
                    </button>
                </>
            )}
        </div>
    );
};

Pattern 2: Polling (Auto-Refresh)

Fetch Data on Interval

const LiveData: React.FC = () => {
    const [data, setData] = useState(null);
    const [isPolling, setIsPolling] = useState(true);
    
    useEffect(() => {
        if (!isPolling) return;
        
        const fetchData = async () => {
            const response = await fetch('https://api.example.com/live-data');
            const json = await response.json();
            setData(json);
        };
        
        // Fetch immediately
        fetchData();
        
        // Then fetch every 5 seconds
        const intervalId = setInterval(fetchData, 5000);
        
        return () => {
            clearInterval(intervalId);
        };
    }, [isPolling]);
    
    return (
        <div>
            <h2>Live Data</h2>
            <button onClick={() => setIsPolling(!isPolling)}>
                {isPolling ? 'Stop Polling' : 'Start Polling'}
            </button>
            <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
    );
};

Pattern 3: Pagination

Load More Pattern

const PostsList: React.FC = () => {
    const [posts, setPosts] = useState<Post[]>([]);
    const [page, setPage] = useState(1);
    const [isLoading, setIsLoading] = useState(false);
    const [hasMore, setHasMore] = useState(true);
    
    useEffect(() => {
        const fetchPosts = async () => {
            setIsLoading(true);
            
            const response = await fetch(
                `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
            );
            const newPosts = await response.json();
            
            if (newPosts.length === 0) {
                setHasMore(false);
            } else {
                setPosts(prev => [...prev, ...newPosts]);
            }
            
            setIsLoading(false);
        };
        
        fetchPosts();
    }, [page]);
    
    const loadMore = () => {
        setPage(prev => prev + 1);
    };
    
    return (
        <div>
            <h2>Posts</h2>
            {posts.map(post => (
                <article key={post.id}>
                    <h3>{post.title}</h3>
                    <p>{post.body}</p>
                </article>
            ))}
            
            {isLoading && <div>Loading more...</div>}
            
            {hasMore && !isLoading && (
                <button onClick={loadMore}>Load More</button>
            )}
            
            {!hasMore && <div>No more posts</div>}
        </div>
    );
};

Pattern 4: Dependent Fetches

Fetch Data Based on Other Data

const UserWithPosts: React.FC<{ userId: number }> = ({ userId }) => {
    const [user, setUser] = useState(null);
    const [posts, setPosts] = useState([]);
    const [isLoadingUser, setIsLoadingUser] = useState(true);
    const [isLoadingPosts, setIsLoadingPosts] = useState(false);
    
    // Fetch user first
    useEffect(() => {
        const fetchUser = async () => {
            setIsLoadingUser(true);
            const response = await fetch(
                `https://jsonplaceholder.typicode.com/users/${userId}`
            );
            const data = await response.json();
            setUser(data);
            setIsLoadingUser(false);
        };
        
        fetchUser();
    }, [userId]);
    
    // Fetch posts after we have user
    useEffect(() => {
        if (!user) return;
        
        const fetchPosts = async () => {
            setIsLoadingPosts(true);
            const response = await fetch(
                `https://jsonplaceholder.typicode.com/posts?userId=${user.id}`
            );
            const data = await response.json();
            setPosts(data);
            setIsLoadingPosts(false);
        };
        
        fetchPosts();
    }, [user]);
    
    if (isLoadingUser) return <div>Loading user...</div>;
    if (isLoadingPosts) return <div>Loading posts...</div>;
    
    return (
        <div>
            <h2>{user?.name}'s Posts</h2>
            {posts.map(post => (
                <article key={post.id}>
                    <h3>{post.title}</h3>
                    <p>{post.body}</p>
                </article>
            ))}
        </div>
    );
};

Pattern 5: Caching Results

Simple Cache Implementation

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

const useCachedFetch = <T,>(url: string) => {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    
    useEffect(() => {
        // Check cache first
        if (cache.has(url)) {
            setData(cache.get(url));
            setIsLoading(false);
            return;
        }
        
        const fetchData = async () => {
            const response = await fetch(url);
            const json = await response.json();
            
            // Save to cache
            cache.set(url, json);
            setData(json);
            setIsLoading(false);
        };
        
        fetchData();
    }, [url]);
    
    return { data, isLoading };
};

๐Ÿ‹๏ธ Hands-on Practice

Time to build real data-fetching components!

๐Ÿ‹๏ธ Exercise 1: User Directory

Build a component that displays a list of users from the API.

Requirements:

  • Fetch all users from: https://jsonplaceholder.typicode.com/users
  • Display name, email, and city for each user
  • Show loading state while fetching
  • Handle and display errors
  • Add a refresh button

Starter Code:

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

const UserDirectory: React.FC = () => {
    // Your code here!
    
    return (
        <div>
            <h1>User Directory</h1>
            {/* Display users */}
        </div>
    );
};
๐Ÿ’ก Hint

Use useState for users array, loading, and error. Use useEffect with empty dependency array to fetch on mount.

โœ… Solution
const UserDirectory: React.FC = () => {
    const [users, setUsers] = useState<User[]>([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    const fetchUsers = async () => {
        try {
            setIsLoading(true);
            setError(null);
            
            const response = await fetch('https://jsonplaceholder.typicode.com/users');
            
            if (!response.ok) {
                throw new Error('Failed to fetch users');
            }
            
            const data = await response.json();
            setUsers(data);
            
        } catch (err) {
            setError(err instanceof Error ? err.message : 'An error occurred');
        } finally {
            setIsLoading(false);
        }
    };
    
    useEffect(() => {
        fetchUsers();
    }, []);
    
    if (isLoading) return <div>Loading users...</div>;
    if (error) return <div>Error: {error}</div>;
    
    return (
        <div>
            <h1>User Directory</h1>
            <button onClick={fetchUsers}>Refresh</button>
            <ul>
                {users.map(user => (
                    <li key={user.id}>
                        <h3>{user.name}</h3>
                        <p>Email: {user.email}</p>
                        <p>City: {user.address.city}</p>
                    </li>
                ))}
            </ul>
        </div>
    );
};

๐Ÿ‹๏ธ Exercise 2: Post Viewer

Create a component that shows a single post with its comments.

Requirements:

  • Accept postId as prop
  • Fetch post: https://jsonplaceholder.typicode.com/posts/{postId}
  • Fetch comments: https://jsonplaceholder.typicode.com/comments?postId={postId}
  • Use Promise.all to fetch both simultaneously
  • Handle loading and error states
  • Cancel requests when component unmounts
โœ… Solution
interface Post {
    id: number;
    title: string;
    body: string;
}

interface Comment {
    id: number;
    name: string;
    email: string;
    body: string;
}

const PostViewer: React.FC<{ postId: number }> = ({ postId }) => {
    const [post, setPost] = useState<Post | null>(null);
    const [comments, setComments] = useState<Comment[]>([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                
                const [postRes, commentsRes] = await Promise.all([
                    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, {
                        signal: controller.signal
                    }),
                    fetch(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`, {
                        signal: controller.signal
                    })
                ]);
                
                const [postData, commentsData] = await Promise.all([
                    postRes.json(),
                    commentsRes.json()
                ]);
                
                setPost(postData);
                setComments(commentsData);
                
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        return () => controller.abort();
    }, [postId]);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!post) return <div>Post not found</div>;
    
    return (
        <div>
            <article>
                <h2>{post.title}</h2>
                <p>{post.body}</p>
            </article>
            
            <section>
                <h3>Comments ({comments.length})</h3>
                {comments.map(comment => (
                    <div key={comment.id}>
                        <h4>{comment.name}</h4>
                        <p>{comment.body}</p>
                        <small>{comment.email}</small>
                    </div>
                ))}
            </section>
        </div>
    );
};

๐Ÿ‹๏ธ Challenge: Search & Filter

Build a searchable, filterable user list!

Requirements:

  • Fetch all users
  • Add search input to filter by name
  • Add dropdown to filter by city
  • Debounce search (wait 500ms after typing)
  • Show "No results" when filters match nothing
  • Display count of filtered users

โœจ Best Practices

โœ… Do's

  • Always handle loading states: Users need feedback
  • Always handle errors: Networks fail, APIs go down
  • Use AbortController: Cancel requests to prevent memory leaks
  • Check response.ok: Fetch doesn't throw on HTTP errors
  • Type your API responses: Create interfaces for data
  • Use async/await: More readable than promise chains
  • Validate API responses: Don't trust external data blindly
  • Show specific error messages: Help users understand what went wrong
  • Provide retry mechanisms: Let users recover from errors
  • Use custom hooks: Extract reusable fetch logic

โŒ Don'ts

  • Don't fetch during render: Always use useEffect
  • Don't forget dependencies: Include all values used in effect
  • Don't ignore AbortError: It's expected, not a real error
  • Don't update state after unmount: Check if request was aborted
  • Don't expose technical errors: Be user-friendly
  • Don't fetch on every render: Use proper dependency arrays
  • Don't forget empty states: Show when there's no data
  • Don't block UI: Show loading indicators, not blank screens
  • Don't trust response format: Validate data structure

Error Handling Strategy

Comprehensive Error Management

const fetchWithErrorHandling = async (url: string) => {
    try {
        const response = await fetch(url);
        
        // HTTP errors
        if (!response.ok) {
            switch (response.status) {
                case 400:
                    throw new Error('Bad request - check your input');
                case 401:
                    throw new Error('Please log in to continue');
                case 403:
                    throw new Error('You don\'t have permission');
                case 404:
                    throw new Error('Resource not found');
                case 500:
                    throw new Error('Server error - try again later');
                case 503:
                    throw new Error('Service unavailable - try again later');
                default:
                    throw new Error(`Error: ${response.status}`);
            }
        }
        
        const data = await response.json();
        return data;
        
    } catch (err) {
        // Network errors
        if (err instanceof TypeError) {
            throw new Error('Network error - check your connection');
        }
        
        // JSON parse errors
        if (err instanceof SyntaxError) {
            throw new Error('Invalid response format');
        }
        
        // Abort errors (don't treat as error)
        if (err instanceof Error && err.name === 'AbortError') {
            return null; // Or handle specially
        }
        
        // Re-throw other errors
        throw err;
    }
};

Loading State Best Practices

Better UX for Loading

// โŒ Bad: Just shows "Loading..."
if (isLoading) return <div>Loading...</div>;

// โœ… Better: Skeleton screen
if (isLoading) {
    return (
        <div className="user-skeleton">
            <div className="skeleton-avatar"></div>
            <div className="skeleton-title"></div>
            <div className="skeleton-text"></div>
        </div>
    );
}

// โœ… Best: Specific message with spinner
if (isLoading) {
    return (
        <div className="loading-state">
            <div className="spinner"></div>
            <p>Loading user profile...</p>
        </div>
    );
}

API Organization

Centralize API Calls

// api/users.ts
const BASE_URL = 'https://jsonplaceholder.typicode.com';

export const usersAPI = {
    getAll: async (): Promise<User[]> => {
        const response = await fetch(`${BASE_URL}/users`);
        if (!response.ok) throw new Error('Failed to fetch users');
        return response.json();
    },
    
    getById: async (id: number): Promise<User> => {
        const response = await fetch(`${BASE_URL}/users/${id}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        return response.json();
    },
    
    create: async (user: Omit<User, 'id'>): Promise<User> => {
        const response = await fetch(`${BASE_URL}/users`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(user)
        });
        if (!response.ok) throw new Error('Failed to create user');
        return response.json();
    },
    
    update: async (id: number, user: Partial<User>): Promise<User> => {
        const response = await fetch(`${BASE_URL}/users/${id}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(user)
        });
        if (!response.ok) throw new Error('Failed to update user');
        return response.json();
    },
    
    delete: async (id: number): Promise<void> => {
        const response = await fetch(`${BASE_URL}/users/${id}`, {
            method: 'DELETE'
        });
        if (!response.ok) throw new Error('Failed to delete user');
    }
};

// Usage in component
const UserProfile: React.FC = () => {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        usersAPI.getById(1)
            .then(setUser)
            .catch(console.error);
    }, []);
    
    // ...
};

Performance Considerations

๐Ÿ’ก Optimize Data Fetching

  • Debounce search inputs: Don't fetch on every keystroke
  • Use pagination: Don't load all data at once
  • Implement caching: Avoid redundant requests
  • Lazy load: Fetch data only when needed
  • Use parallel requests: Promise.all for independent data
  • Set timeouts: Don't let requests hang forever
  • Optimize payload size: Request only needed fields
// Example: Request with timeout
const fetchWithTimeout = async (url: string, timeout = 5000) => {
    const controller = new AbortController();
    
    const timeoutId = setTimeout(() => controller.abort(), timeout);
    
    try {
        const response = await fetch(url, { signal: controller.signal });
        clearTimeout(timeoutId);
        return response;
    } catch (err) {
        clearTimeout(timeoutId);
        throw err;
    }
};

Security Considerations

Secure Data Fetching

  • Never expose API keys in frontend code
  • Use HTTPS: Always, never HTTP
  • Sanitize user input: Before sending to API
  • Validate responses: Don't trust API data blindly
  • Handle sensitive data carefully: Don't log passwords, tokens
  • Implement CORS properly: Understand cross-origin requests
  • Use authentication tokens: Store securely, not in localStorage for sensitive apps

Testing Data Fetching

How to Test API Calls

// Mock fetch in tests
global.fetch = jest.fn(() =>
    Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ id: 1, name: 'Test User' })
    })
) as jest.Mock;

// Test component
test('fetches and displays user', async () => {
    render(<UserProfile userId={1} />);
    
    // Check loading state
    expect(screen.getByText('Loading...')).toBeInTheDocument();
    
    // Wait for data to load
    await waitFor(() => {
        expect(screen.getByText('Test User')).toBeInTheDocument();
    });
    
    // Verify fetch was called
    expect(fetch).toHaveBeenCalledWith(
        'https://api.example.com/users/1'
    );
});

๐Ÿ“š Summary

What You Learned

Congratulations! You've mastered data fetching in Reactโ€”a critical skill for building real-world applications:

  • โœ… Understanding the Fetch API and how it works
  • โœ… Integrating fetch with useEffect for data fetching
  • โœ… Managing loading states to provide user feedback
  • โœ… Handling errors gracefully with proper error messages
  • โœ… Typing API responses with TypeScript interfaces
  • โœ… Using async/await for cleaner asynchronous code
  • โœ… Canceling requests with AbortController
  • โœ… Building reusable fetch hooks and patterns
  • โœ… Implementing common patterns (pagination, polling, refetch)
  • โœ… Following best practices for production apps

๐ŸŽฏ Key Takeaways

  • Always use useEffect: Never fetch during render
  • Handle three states: Loading, error, and success
  • Cancel your requests: Use AbortController to prevent leaks
  • Check response.ok: Fetch doesn't throw on HTTP errors
  • Type everything: TypeScript makes APIs safer
  • Think about UX: Good loading and error states matter

Data Fetching Checklist

โœ… Before You Ship

  • โ˜ Loading state implemented
  • โ˜ Error state handled
  • โ˜ Empty state shown when no data
  • โ˜ AbortController used for cancellation
  • โ˜ Response status checked (response.ok)
  • โ˜ API responses typed with TypeScript
  • โ˜ Error messages user-friendly
  • โ˜ Retry mechanism available
  • โ˜ No infinite loops or memory leaks
  • โ˜ Loading indicators visible and clear

Complete Example Reference

Full Pattern to Copy

import { useState, useEffect } from 'react';

interface DataType {
    // Your data structure
}

const MyComponent: React.FC<{ id: number }> = ({ id }) => {
    const [data, setData] = useState<DataType | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                setError(null);
                
                const response = await fetch(`https://api.example.com/data/${id}`, {
                    signal: controller.signal
                });
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const json = await response.json();
                setData(json);
                
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        return () => controller.abort();
    }, [id]);
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    if (!data) return <div>No data</div>;
    
    return <div>{/* Display data */}</div>;
};

Quick Reference

Task Code
Basic Fetch fetch(url).then(r => r.json())
With Async/Await const res = await fetch(url); const data = await res.json();
Check Status if (!response.ok) throw new Error();
Cancel Request controller.abort()
Handle Abort if (err.name !== 'AbortError') ...
POST Request fetch(url, {method: 'POST', body: JSON.stringify(data)})

๐Ÿš€ What's Next?

In the next lesson, we'll learn about Custom Hooks:

  • Creating reusable hook logic
  • Building custom hooks for data fetching
  • Hook composition and patterns
  • Sharing logic between components
  • Advanced custom hook techniques

You'll take your data fetching code and make it truly reusable! ๐Ÿ’ช

Additional Resources

๐Ÿ“– Further Reading

๐Ÿ› ๏ธ Useful Libraries

  • Axios: Popular alternative to fetch with better defaults
  • React Query: Powerful data fetching and caching library
  • SWR: React Hooks for data fetching by Vercel
  • Zod: TypeScript-first schema validation

Congratulations! ๐ŸŽ‰

๐Ÿ† You're Now a Data Fetching Pro!

You've learned how to fetch data from APIs, handle all edge cases, and build production-ready components. This is a fundamental skill that you'll use in every React application you build.

Key Achievement: You can now integrate any REST API into your React applications with confidence!

Keep practicing with different APIs, and you'll soon be building complex, data-driven applications! ๐Ÿš€