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 wire data fetching into React, let's get comfortable with how the Fetch API behaves on its own. Almost every app you'll ever build talks to a server: a weather widget asks an API for today's forecast, a feed asks for the latest posts, a checkout page asks whether a coupon is valid. That conversation almost always flows through fetch. Understand it well here, in plain TypeScript, and the React parts later will feel like small, familiar wrappers around something you already know.

๐Ÿ“– Definition

Fetch API: A modern, promise-based interface for making HTTP requests. It's built into every current browser and into Node.js (18 and newer), so there is nothing to install. A handy mental model: fetch is a waiter. You hand it an order (a URL, plus optional method, headers, and body), it walks off to the kitchen (the server), and eventually it returns with a covered tray (a Response). Getting your actual food means lifting the lid, which is what response.json() does.

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: Does This Fetch Code Type-Check?

Here's a subtle trap worth meeting early. response.json() is typed as Promise<any>, so TypeScript will happily let you pin any type onto the result โ€” even when it hasn't verified a single field. Click each example below to see whether the compiler accepts it, and why. Watch for the cases that compile but still leave you exposed.

const res = await fetch(url); // res: Response โ€” then read the body

๐Ÿ‘† Click an example to see what TypeScript thinks.

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: Narrowing a Discriminated Union

The cleanest way to model these states is a discriminated union โ€” one status field that TypeScript uses to figure out which other fields exist. The payoff: data is only reachable once you've proven status === 'success', so you can never render a half-loaded value by accident. Click each access below to see whether the compiler allows it.

type State = {status:'loading'} | {status:'success', data:User} | {status:'error', error:string}

๐Ÿ‘† Click an access to see whether TypeScript allows it.

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!
};

โš ๏ธ as T is a promise you make, not one TypeScript keeps

That return data as T is a type assertion: you're telling the compiler "trust me, this is a User." TypeScript takes you at your word and stops checking. But the JSON came from a network response you don't control โ€” the API could rename a field, send null, or return an error object with a 200 status. At that point your type says User while the value is anything but, and the mismatch surfaces later as a confusing undefined is not an object crash far from the real cause.

The honest signature is response.json() as Promise<unknown>: data you haven't verified. To turn that unknown into a trustworthy User, validate it at runtime โ€” with a hand-written type guard (next) or a schema library like Zod (below). Reach for as only when you truly know more than the compiler and have already checked the shape yourself.

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: Handling the Caught Error Safely

In modern TypeScript, the variable in a catch block is typed unknown โ€” the compiler forces you to prove what it is before you touch it. That's a good thing here: it stops you from reading err.message on something that might not even be an Error. Click each line to see which ones the compiler accepts inside catch (err) { ... }.

try { await fetch(url, { signal }); } catch (err) { /* err: unknown */ }

๐Ÿ‘† Click a line to see whether TypeScript allows it.

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>
            );
    }
};

Where This Goes in a Real App

Everything above is worth writing by hand once, so you understand exactly what a data-fetching component is doing under the hood. But be honest about what you've built: the useFetch hook has no caching, no request de-duplication, no background refetching, no retry-with-backoff, no shared cache across components. In production you almost never re-implement those from scratch โ€” you reach for a library that already got them right.

๐Ÿ† The production standard: TanStack Query

TanStack Query (formerly React Query) is the de facto way most React teams fetch data today. You describe what you want with a query key and a fetch function, and it hands back loading, error, and data states โ€” plus caching, automatic refetching, deduplication, and cancellation, all for free.

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

async function fetchUser(id: number, signal: AbortSignal): Promise<User> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>; // still validate for real APIs!
}

function UserProfile({ userId }: { userId: number }) {
  const { data: user, isPending, error } = useQuery({
    queryKey: ['user', userId],                         // cache key
    queryFn: ({ signal }) => fetchUser(userId, signal), // cancellation built in
  });

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

Notice how the whole useEffect + AbortController + three useState dance collapses into a few lines. The manual pattern you learned still runs underneath โ€” you just stop maintaining it yourself.

๐Ÿ”ฎ A modern direction: React 19's use() hook

React 19 adds use(), which reads a promise directly during render. Instead of juggling loading and error state inside the component, you let <Suspense> show the fallback while the promise is pending and an error boundary catch a rejection. The component itself only describes the happy path:

import { use, Suspense } from 'react';

// userPromise is created by a parent (or your data layer), not inside this component
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // suspends until the promise resolves
  return <h2>{user.name}</h2>;
}

function Page() {
  const userPromise = fetchUser(1); // cache/dedupe this so it isn't recreated every render
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

It's a genuinely different mental model โ€” declarative loading via Suspense rather than if (isLoading) checks. In practice you'll usually let a framework (like Next.js) or TanStack Query create and cache those promises for you, rather than calling fetch straight in render. Learn the manual pattern first; it makes both of these click.

๐ŸŽจ 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

  • TanStack Query (formerly React Query): the production standard for fetching, caching, and background updates
  • SWR: a lighter React-Hooks data-fetching library by Vercel
  • Zod (or Valibot): TypeScript-first runtime validation โ€” turn unknown API data into trustworthy types
  • ky: a tiny, modern wrapper around fetch with sensible defaults (a lighter alternative to Axios)

Congratulations! ๐ŸŽ‰

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

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

Key Achievement: You can now integrate any REST API into your React applications with confidence โ€” and you understand the manual machinery well enough to know exactly what TanStack Query and Suspense are doing for you.

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