AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Building a Custom useFetch Hook: Data Fetching Patterns

Last updated:
Building a Custom useFetch Hook: Data Fetching Patterns

Master building a production-ready useFetch hook in React 19. Learn error handling, loading states, request cancellation, and caching with complete TypeScript examples.

# Building a Custom useFetch Hook: Data Fetching Patterns

Data fetching is one of the most fundamental tasks in modern web applications. Yet it's also surprisingly complex. You need to handle loading states, errors, retries, caching, request cancellation, and race conditions—all while keeping your component code clean. That's where a well-designed custom useFetch hook becomes invaluable.

In this comprehensive guide, you'll learn how to build a production-ready useFetch hook from scratch. We'll explore different patterns, from a simple implementation to an advanced version with caching and cancellation support. By the end, you'll understand not just how to build one, but when and why to use custom hooks for data fetching instead of relying solely on libraries.

# Table of Contents

  1. Why Build a Custom Hook?
  2. Basic Implementation
  3. Adding Error Handling
  4. Managing Loading States
  5. Request Cancellation
  6. Caching Strategy
  7. Advanced Patterns
  8. Common Pitfalls
  9. FAQ

# Why Build a Custom Hook?

Before diving into code, let's establish when a custom useFetch hook makes sense.

# The Problem with Inline Fetch

Most beginners start with useEffect and fetch directly in components:

typescript
// ❌ Anti-pattern: fetch logic mixed with component logic
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (isMounted) setUser(data);
      })
      .catch(err => {
        if (isMounted) setError(err);
      })
      .finally(() => {
        if (isMounted) setLoading(false);
      });

    return () => {
      isMounted = false;
    };
  }, [userId]);

  // Rest of component...
}

This works, but it's verbose, repetitive, and error-prone. Every component that fetches data repeats this pattern. A custom hook extracts this logic and makes it reusable.

# Benefits of useFetch Hook

  • Reusability: Write once, use in many components
  • Consistency: Same error handling, loading states everywhere
  • Testability: Hook logic is separate from component logic
  • Maintainability: Fix bugs in one place
  • Clarity: Component code focuses on UI, not fetch mechanics

# Basic Implementation

Let's start with the simplest working version.

# TypeScript Version

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

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

export function useFetch<T>(url: string): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    // Prevent race conditions
    let isMounted = true;

    const fetchData = async () => {
      try {
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const json = await response.json();

        if (isMounted) {
          setState({ data: json, loading: false, error: null });
        }
      } catch (err) {
        if (isMounted) {
          setState({
            data: null,
            loading: false,
            error: err instanceof Error ? err : new Error('Unknown error')
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url]);

  return state;
}

# JavaScript Version

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

export function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      try {
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const json = await response.json();

        if (isMounted) {
          setState({ data: json, loading: false, error: null });
        }
      } catch (err) {
        if (isMounted) {
          setState({
            data: null,
            loading: false,
            error: err instanceof Error ? err : new Error('Unknown error')
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url]);

  return state;
}

Usage:

typescript
function UserList() {
  const { data: users, loading, error } = useFetch<User[]>('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;

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

The isMounted flag prevents the notorious "can't perform a React state update on an unmounted component" warning.

# Adding Error Handling

Basic error handling is good, but production apps need more sophisticated approaches.

# TypeScript Version

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

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  statusCode: number | null;
}

interface UseFetchOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers?: Record<string, string>;
  body?: Record<string, unknown>;
  shouldRetry?: boolean;
  maxRetries?: number;
}

export function useFetch<T>(
  url: string,
  options?: UseFetchOptions
): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null,
    statusCode: null
  });

  const {
    method = 'GET',
    headers = {},
    body,
    shouldRetry = true,
    maxRetries = 3
  } = options || {};

  useEffect(() => {
    let isMounted = true;
    let retryCount = 0;

    const fetchData = async () => {
      try {
        const config: RequestInit = {
          method,
          headers: {
            'Content-Type': 'application/json',
            ...headers
          }
        };

        if (body) {
          config.body = JSON.stringify(body);
        }

        const response = await fetch(url, config);

        if (!response.ok) {
          // Retry on 5xx errors
          if (
            shouldRetry &&
            retryCount < maxRetries &&
            response.status >= 500
          ) {
            retryCount++;
            // Exponential backoff
            await new Promise(resolve =>
              setTimeout(resolve, Math.pow(2, retryCount) * 1000)
            );
            return fetchData();
          }

          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const json = await response.json();

        if (isMounted) {
          setState({
            data: json,
            loading: false,
            error: null,
            statusCode: response.status
          });
        }
      } catch (err) {
        if (isMounted) {
          const error = err instanceof Error ? err : new Error('Unknown error');
          setState({
            data: null,
            loading: false,
            error,
            statusCode: null
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url, method, JSON.stringify(body), JSON.stringify(headers), shouldRetry, maxRetries]);

  return state;
}

# JavaScript Version

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

export function useFetch(url, options = {}) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
    statusCode: null
  });

  const {
    method = 'GET',
    headers = {},
    body,
    shouldRetry = true,
    maxRetries = 3
  } = options;

  useEffect(() => {
    let isMounted = true;
    let retryCount = 0;

    const fetchData = async () => {
      try {
        const config = {
          method,
          headers: {
            'Content-Type': 'application/json',
            ...headers
          }
        };

        if (body) {
          config.body = JSON.stringify(body);
        }

        const response = await fetch(url, config);

        if (!response.ok) {
          if (
            shouldRetry &&
            retryCount < maxRetries &&
            response.status >= 500
          ) {
            retryCount++;
            await new Promise(resolve =>
              setTimeout(resolve, Math.pow(2, retryCount) * 1000)
            );
            return fetchData();
          }

          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const json = await response.json();

        if (isMounted) {
          setState({
            data: json,
            loading: false,
            error: null,
            statusCode: response.status
          });
        }
      } catch (err) {
        if (isMounted) {
          const error = err instanceof Error ? err : new Error('Unknown error');
          setState({
            data: null,
            loading: false,
            error,
            statusCode: null
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url, method, body, headers, shouldRetry, maxRetries]);

  return state;
}

Key Features:

  • HTTP error detection
  • Automatic retry with exponential backoff
  • Configurable headers and request body
  • Status code tracking

# Managing Loading States

More granular loading states help create better UX:

# TypeScript Version

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

type FetchStatus = 'idle' | 'loading' | 'success' | 'error';

interface UseFetchState<T> {
  data: T | null;
  status: FetchStatus;
  error: Error | null;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
}

export function useFetch<T>(
  url: string | null
): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    status: 'idle',
    error: null,
    isLoading: false,
    isError: false,
    isSuccess: false
  });

  useEffect(() => {
    // Don't fetch if URL is null (useful for conditional fetching)
    if (!url) {
      setState({
        data: null,
        status: 'idle',
        error: null,
        isLoading: false,
        isError: false,
        isSuccess: false
      });
      return;
    }

    let isMounted = true;

    const fetchData = async () => {
      setState(prev => ({
        ...prev,
        status: 'loading',
        isLoading: true
      }));

      try {
        const response = await fetch(url);

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

        const json = await response.json();

        if (isMounted) {
          setState({
            data: json,
            status: 'success',
            error: null,
            isLoading: false,
            isError: false,
            isSuccess: true
          });
        }
      } catch (err) {
        if (isMounted) {
          const error = err instanceof Error ? err : new Error('Unknown error');
          setState({
            data: null,
            status: 'error',
            error,
            isLoading: false,
            isError: true,
            isSuccess: false
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url]);

  return state;
}

# JavaScript Version

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

export function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    status: 'idle',
    error: null,
    isLoading: false,
    isError: false,
    isSuccess: false
  });

  useEffect(() => {
    if (!url) {
      setState({
        data: null,
        status: 'idle',
        error: null,
        isLoading: false,
        isError: false,
        isSuccess: false
      });
      return;
    }

    let isMounted = true;

    const fetchData = async () => {
      setState(prev => ({
        ...prev,
        status: 'loading',
        isLoading: true
      }));

      try {
        const response = await fetch(url);

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

        const json = await response.json();

        if (isMounted) {
          setState({
            data: json,
            status: 'success',
            error: null,
            isLoading: false,
            isError: false,
            isSuccess: true
          });
        }
      } catch (err) {
        if (isMounted) {
          const error = err instanceof Error ? err : new Error('Unknown error');
          setState({
            data: null,
            status: 'error',
            error,
            isLoading: false,
            isError: true,
            isSuccess: false
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url]);

  return state;
}

Usage:

typescript
function DataList() {
  const { data, status, isLoading, isError, error } = useFetch('/api/data');

  return (
    <div>
      {status === 'idle' && <p>开始加载...</p>}
      {isLoading && <p>加载中...</p>}
      {isError && <p>错误: {error?.message}</p>}
      {status === 'success' && (
        <ul>
          {data?.map(item => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

# Request Cancellation

AbortController allows canceling in-flight requests when components unmount:

# TypeScript Version

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

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

export function useFetch<T>(url: string): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    // Create AbortController for this fetch
    const controller = new AbortController();

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

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

        const json = await response.json();

        setState({
          data: json,
          loading: false,
          error: null
        });
      } catch (err) {
        // Don't update state if request was aborted
        if (err instanceof DOMException && err.name === 'AbortError') {
          return;
        }

        const error = err instanceof Error ? err : new Error('Unknown error');
        setState({
          data: null,
          loading: false,
          error
        });
      }
    };

    fetchData();

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

  return state;
}

# JavaScript Version

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

export function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null
  });

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

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

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

        const json = await response.json();

        setState({
          data: json,
          loading: false,
          error: null
        });
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return;
        }

        const error = err instanceof Error ? err : new Error('Unknown error');
        setState({
          data: null,
          loading: false,
          error
        });
      }
    };

    fetchData();

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

  return state;
}

AbortController is cleaner than the isMounted flag and is the modern standard.

# Caching Strategy

Caching prevents redundant requests and improves performance:

# TypeScript Version

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

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

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

// Simple in-memory cache
const cache = new Map<string, CacheEntry<unknown>>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

export function useFetch<T>(
  url: string,
  { skipCache = false }: { skipCache?: boolean } = {}
): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    // Check cache first
    if (!skipCache && cache.has(url)) {
      const entry = cache.get(url) as CacheEntry<T>;
      const isExpired = Date.now() - entry.timestamp > CACHE_DURATION;

      if (!isExpired) {
        setState({ data: entry.data, loading: false, error: null });
        return;
      }
    }

    const controller = new AbortController();

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

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

        const json = await response.json();

        // Store in cache
        cache.set(url, { data: json, timestamp: Date.now() });

        setState({ data: json, loading: false, error: null });
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return;
        }

        const error = err instanceof Error ? err : new Error('Unknown error');
        setState({ data: null, loading: false, error });
      }
    };

    fetchData();

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

  return state;
}

# JavaScript Version

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

const cache = new Map();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

export function useFetch(url, { skipCache = false } = {}) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null
  });

  useEffect(() => {
    if (!skipCache && cache.has(url)) {
      const entry = cache.get(url);
      const isExpired = Date.now() - entry.timestamp > CACHE_DURATION;

      if (!isExpired) {
        setState({ data: entry.data, loading: false, error: null });
        return;
      }
    }

    const controller = new AbortController();

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

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

        const json = await response.json();

        cache.set(url, { data: json, timestamp: Date.now() });

        setState({ data: json, loading: false, error: null });
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return;
        }

        const error = err instanceof Error ? err : new Error('Unknown error');
        setState({ data: null, loading: false, error });
      }
    };

    fetchData();

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

  return state;
}

# Advanced Patterns

# Pattern 1: Manual Refetch

Allow components to manually trigger refetches:

typescript
interface UseFetchReturn<T> extends UseFetchState<T> {
  refetch: () => Promise<void>;
}

export function useFetch<T>(url: string): UseFetchReturn<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null
  });

  const refetch = async () => {
    setState(prev => ({ ...prev, loading: true }));
    // Fetch logic...
  };

  return { ...state, refetch };
}

# Pattern 2: Conditional Fetching

Allow skipping fetch when URL is null:

typescript
export function useFetch<T>(
  url: string | null
): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: !!url, // Only loading if URL is provided
    error: null
  });

  useEffect(() => {
    if (!url) return;
    // Fetch logic...
  }, [url]);

  return state;
}

# Pattern 3: Transform Data

Apply transformations to fetched data:

typescript
export function useFetch<T, R>(
  url: string,
  transform?: (data: T) => R
): UseFetchState<R> {
  // Fetch logic...
  const transformedData = transform ? transform(json) : json;
  // Set state with transformed data
}

# Common Pitfalls

# Pitfall 1: Missing Dependency Array

typescript
// ❌ Wrong - fetches on every render
useEffect(() => {
  fetch(url).then(res => res.json()).then(setData);
}); // No dependency array!

// ✅ Correct
useEffect(() => {
  fetch(url).then(res => res.json()).then(setData);
}, [url]); // Dependency array included

# Pitfall 2: Comparing Objects in Dependencies

typescript
// ❌ Bad - creates new object each render
const options = { method: 'POST' };
useFetch(url, options);

// ✅ Better - move outside or use useMemo
const options = useMemo(() => ({ method: 'POST' }), []);
useFetch(url, options);

# Pitfall 3: Not Handling Race Conditions

typescript
// ❌ Bad - can set stale data if requests complete out of order
useEffect(() => {
  fetch(url).then(res => setState(res));
}, [url]);

// ✅ Good - uses AbortController
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal });
  return () => controller.abort();
}, [url]);

# Pitfall 4: Infinite Loops with Objects

typescript
// ❌ Infinite loop - new object each render
useFetch(url, { headers: { Authorization: token } });

// ✅ Fixed - memoize options
const options = useMemo(
  () => ({ headers: { Authorization: token } }),
  [token]
);
useFetch(url, options);

# FAQ

# Q: Should I use useFetch or a library like React Query?

A: For simple apps, useFetch is fine. For production apps with complex caching, background refetching, and synchronization needs, React Query/TanStack Query is better. Start with useFetch and upgrade if needed.

# Q: Can I use useFetch with GraphQL?

A: Yes, just change the fetch call:

typescript
export function useGraphQL<T>(query: string): UseFetchState<T> {
  // Same pattern, but POST to GraphQL endpoint
  return useFetch(
    '/graphql',
    { method: 'POST', body: { query } }
  );
}

# Q: How do I handle POST requests with useFetch?

A: Pass method and body options:

typescript
const { data, error, loading } = useFetch(url, {
  method: 'POST',
  body: { name: 'John', email: 'john@example.com' }
});

# Q: Is AbortController supported in older browsers?

A: It's supported in modern browsers (>95% usage). For IE11 support, fall back to isMounted flag or use a polyfill.

# Q: How do I cache data globally across components?

A: Use Context API or a state management library like Zustand. Or upgrade to React Query for built-in global caching.

# Q: Can I use useFetch in Server Components?

A: No, hooks only work in Client Components. For Server Components, use native fetch directly.

# Q: How do I handle authentication with useFetch?

A: Add headers to requests:

typescript
const authToken = useAuth();
const { data } = useFetch(url, {
  headers: {
    Authorization: `Bearer ${authToken}`
  }
});

# Practical Scenario: Chinese E-commerce App

In a typical Chinese e-commerce app (like Alibaba or JD.com), you need to fetch product listings, filter by category, and handle pagination. Here's how useFetch integrates:

# TypeScript Version

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

interface FetchProductsParams {
  category: string;
  page: number;
  perPage?: number;
}

export function useProducts({ category, page, perPage = 20 }: FetchProductsParams) {
  const queryParams = new URLSearchParams({
    category,
    page: page.toString(),
    perPage: perPage.toString()
  });

  const url = `/api/products?${queryParams}`;
  return useFetch<Product[]>(url);
}

// In component
function ProductList({ category }: { category: string }) {
  const [page, setPage] = useState(1);
  const { data: products, loading, error } = useProducts({ category, page });

  if (loading) return <div>加载商品中...</div>;
  if (error) return <div>加载失败: {error.message}</div>;

  return (
    <div>
      <div className="products">
        {products?.map(product => (
          <div key={product.id} className="product-card">
            <img src={product.image} alt={product.name} />
            <h3>{product.name}</h3>
            <p>¥{product.price}</p>
          </div>
        ))}
      </div>
      <button onClick={() => setPage(p => p + 1)}>下一页</button>
    </div>
  );
}

# JavaScript Version

javascript
export function useProducts({ category, page, perPage = 20 }) {
  const queryParams = new URLSearchParams({
    category,
    page: page.toString(),
    perPage: perPage.toString()
  });

  const url = `/api/products?${queryParams}`;
  return useFetch(url);
}

function ProductList({ category }) {
  const [page, setPage] = useState(1);
  const { data: products, loading, error } = useProducts({ category, page });

  if (loading) return <div>加载商品中...</div>;
  if (error) return <div>加载失败: {error.message}</div>;

  return (
    <div>
      <div className="products">
        {products?.map(product => (
          <div key={product.id} className="product-card">
            <img src={product.image} alt={product.name} />
            <h3>{product.name}</h3>
            <p>¥{product.price}</p>
          </div>
        ))}
      </div>
      <button onClick={() => setPage(p => p + 1)}>下一页</button>
    </div>
  );
}

Related Articles:

Questions? Have you built a custom useFetch hook before? What features did you find most useful? Or are you considering migrating to React Query? Share your experience in the comments below!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT