How to Build a Custom React Hook for API Calls

ReactJS|SEPTEMBER 15, 2025|0 VIEWS
Learn to build powerful, reusable custom React hooks for handling API calls with loading states, error handling, and caching mechanisms

Introduction

API calls are fundamental to modern web applications, but managing loading states, error handling, and data caching can quickly become repetitive and error-prone. Custom React hooks provide an elegant solution to encapsulate this logic and create reusable, maintainable code.

In this comprehensive guide, we'll build a robust custom hook for API calls that handles:

  • Loading states
  • Error management
  • Request cancellation
  • Caching mechanisms
  • Retry logic
  • Type safety with TypeScript

By the end of this tutorial, you'll have a production-ready hook that simplifies API management across your React applications.

Understanding Custom Hooks

What Are Custom Hooks?

Custom hooks are JavaScript functions that:

  • Start with the prefix "use"
  • Can call other hooks
  • Allow you to extract component logic into reusable functions
  • Follow the Rules of Hooks
// Basic custom hook structure
function useCustomHook() {
  // Hook logic here
  return /* hook return value */;
}

Why Build a Custom API Hook?

Traditional API calls in React often look like this:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

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

This pattern repeats across components, leading to code duplication and maintenance challenges.

Building the Basic useApi Hook

Let's start with a simple version and progressively enhance it:

Step 1: Basic Hook Structure

import { useState, useEffect } from 'react';

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

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

        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

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

    if (url) {
      fetchData();
    }
  }, [url]);

  return { data, loading, error };
}

Step 2: Using the Basic Hook

function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);

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

Much cleaner! But we can make it even better.

Enhanced useApi Hook with Advanced Features

Step 3: Adding Request Cancellation

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

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        // Cancel previous request if it exists
        if (abortControllerRef.current) {
          abortControllerRef.current.abort();
        }

        // Create new AbortController for this request
        abortControllerRef.current = new AbortController();
        const { signal } = abortControllerRef.current;

        setLoading(true);
        setError(null);

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

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

        const result = await response.json();

        // Only update state if request wasn't cancelled
        if (!signal.aborted) {
          setData(result);
        }
      } catch (err) {
        // Don't set error for aborted requests
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    if (url) {
      fetchData();
    }

    // Cleanup: abort request on unmount or dependency change
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url, JSON.stringify(options)]);

  return { data, loading, error };
}

Step 4: Adding Manual Refetch Capability

function useApi(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  const fetchData = async (fetchUrl = url, fetchOptions = options) => {
    try {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      abortControllerRef.current = new AbortController();
      const { signal } = abortControllerRef.current;

      setLoading(true);
      setError(null);

      const response = await fetch(fetchUrl, {
        ...fetchOptions,
        signal,
      });

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

      const result = await response.json();

      if (!signal.aborted) {
        setData(result);
      }

      return result;
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
        throw err;
      }
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    if (url) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url, JSON.stringify(options)]);

  const refetch = () => fetchData();
  const cancel = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  };

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

Adding TypeScript Support

Let's make our hook type-safe:

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

interface ApiState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

interface UseApiOptions extends RequestInit {
  skip?: boolean;
  onSuccess?: (data: any) => void;
  onError?: (error: string) => void;
}

interface UseApiReturn<T> extends ApiState<T> {
  refetch: () => Promise<T | undefined>;
  cancel: () => void;
}

function useApi<T = any>(
  url: string | null,
  options: UseApiOptions = {}
): UseApiReturn<T> {
  const { skip = false, onSuccess, onError, ...fetchOptions } = options;

  const [state, setState] = useState<ApiState<T>>({
    data: null,
    loading: false,
    error: null,
  });

  const abortControllerRef = useRef<AbortController | null>(null);

  const fetchData = async (
    fetchUrl: string = url!,
    customOptions: RequestInit = {}
  ): Promise<T | undefined> => {
    try {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      abortControllerRef.current = new AbortController();
      const { signal } = abortControllerRef.current;

      setState((prev) => ({ ...prev, loading: true, error: null }));

      const response = await fetch(fetchUrl, {
        ...fetchOptions,
        ...customOptions,
        signal,
      });

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

      const result: T = await response.json();

      if (!signal.aborted) {
        setState((prev) => ({ ...prev, data: result, loading: false }));
        onSuccess?.(result);
      }

      return result;
    } catch (err) {
      const errorMessage =
        err instanceof Error ? err.message : 'An error occurred';

      if (err instanceof Error && err.name !== 'AbortError') {
        setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
        onError?.(errorMessage);
        throw err;
      }
    }
  };

  useEffect(() => {
    if (url && !skip) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url, skip, JSON.stringify(fetchOptions)]);

  const refetch = () => fetchData();

  const cancel = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  };

  return {
    ...state,
    refetch,
    cancel,
  };
}

Advanced Features: Caching and Retry Logic

Adding Simple Caching

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

class ApiCache {
  private cache = new Map<string, CacheEntry<any>>();

  set<T>(key: string, data: T, expiresIn: number = 5 * 60 * 1000): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      expiresIn,
    });
  }

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);

    if (!entry) return null;

    const isExpired = Date.now() - entry.timestamp > entry.expiresIn;

    if (isExpired) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

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

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

const apiCache = new ApiCache();

// Enhanced hook with caching
function useApi<T = any>(
  url: string | null,
  options: UseApiOptions & {
    cacheKey?: string;
    cacheTime?: number;
    staleWhileRevalidate?: boolean;
  } = {}
): UseApiReturn<T> {
  const {
    skip = false,
    onSuccess,
    onError,
    cacheKey,
    cacheTime = 5 * 60 * 1000, // 5 minutes default
    staleWhileRevalidate = false,
    ...fetchOptions
  } = options;

  const effectiveCacheKey = cacheKey || url;
  const [state, setState] = useState<ApiState<T>>(() => {
    // Try to get cached data on initialization
    if (effectiveCacheKey) {
      const cachedData = apiCache.get<T>(effectiveCacheKey);
      if (cachedData) {
        return {
          data: cachedData,
          loading: false,
          error: null,
        };
      }
    }

    return {
      data: null,
      loading: false,
      error: null,
    };
  });

  const abortControllerRef = useRef<AbortController | null>(null);

  const fetchData = async (
    fetchUrl: string = url!,
    customOptions: RequestInit = {},
    skipCache: boolean = false
  ): Promise<T | undefined> => {
    // Check cache first (unless skipCache is true)
    if (!skipCache && effectiveCacheKey) {
      const cachedData = apiCache.get<T>(effectiveCacheKey);
      if (cachedData) {
        if (!staleWhileRevalidate) {
          setState((prev) => ({ ...prev, data: cachedData, loading: false }));
          return cachedData;
        } else {
          // Return stale data immediately, but continue to fetch fresh data
          setState((prev) => ({ ...prev, data: cachedData }));
        }
      }
    }

    try {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      abortControllerRef.current = new AbortController();
      const { signal } = abortControllerRef.current;

      setState((prev) => ({ ...prev, loading: true, error: null }));

      const response = await fetch(fetchUrl, {
        ...fetchOptions,
        ...customOptions,
        signal,
      });

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

      const result: T = await response.json();

      if (!signal.aborted) {
        setState((prev) => ({ ...prev, data: result, loading: false }));

        // Cache the result
        if (effectiveCacheKey) {
          apiCache.set(effectiveCacheKey, result, cacheTime);
        }

        onSuccess?.(result);
      }

      return result;
    } catch (err) {
      const errorMessage =
        err instanceof Error ? err.message : 'An error occurred';

      if (err instanceof Error && err.name !== 'AbortError') {
        setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
        onError?.(errorMessage);
        throw err;
      }
    }
  };

  useEffect(() => {
    if (url && !skip) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url, skip, effectiveCacheKey, JSON.stringify(fetchOptions)]);

  const refetch = (skipCache: boolean = true) => fetchData(url!, {}, skipCache);

  const cancel = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  };

  return {
    ...state,
    refetch,
    cancel,
  };
}

Adding Retry Logic

interface UseApiOptions extends RequestInit {
  skip?: boolean;
  onSuccess?: (data: any) => void;
  onError?: (error: string) => void;
  cacheKey?: string;
  cacheTime?: number;
  staleWhileRevalidate?: boolean;
  retries?: number;
  retryDelay?: number;
  retryCondition?: (error: Error, attempt: number) => boolean;
}

function useApi<T = any>(
  url: string | null,
  options: UseApiOptions = {}
): UseApiReturn<T> {
  const {
    skip = false,
    onSuccess,
    onError,
    cacheKey,
    cacheTime = 5 * 60 * 1000,
    staleWhileRevalidate = false,
    retries = 0,
    retryDelay = 1000,
    retryCondition = (error: Error, attempt: number) =>
      attempt <= retries && !error.message.includes('404'),
    ...fetchOptions
  } = options;

  // ... previous state and cache logic ...

  const fetchDataWithRetry = async (
    fetchUrl: string = url!,
    customOptions: RequestInit = {},
    skipCache: boolean = false,
    attempt: number = 0
  ): Promise<T | undefined> => {
    try {
      return await fetchData(fetchUrl, customOptions, skipCache);
    } catch (error) {
      const err = error as Error;

      if (retryCondition(err, attempt) && attempt < retries) {
        // Wait before retrying
        await new Promise((resolve) =>
          setTimeout(resolve, retryDelay * Math.pow(2, attempt))
        );
        return fetchDataWithRetry(
          fetchUrl,
          customOptions,
          skipCache,
          attempt + 1
        );
      }

      throw error;
    }
  };

  // Update useEffect to use retry logic
  useEffect(() => {
    if (url && !skip) {
      fetchDataWithRetry();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [url, skip, effectiveCacheKey, JSON.stringify(fetchOptions)]);

  const refetch = (skipCache: boolean = true) =>
    fetchDataWithRetry(url!, {}, skipCache, 0);

  // ... rest of the hook ...
}

Real-World Usage Examples

Example 1: User Profile Component

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

function UserProfile({ userId }: { userId: number }) {
  const {
    data: user,
    loading,
    error,
    refetch,
  } = useApi<User>(`/api/users/${userId}`, {
    cacheTime: 10 * 60 * 1000, // Cache for 10 minutes
    retries: 2,
    onError: (error) => {
      console.error('Failed to fetch user:', error);
    },
  });

  if (loading) return <UserSkeleton />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;

  return (
    <div className="user-profile">
      <img src={user?.avatar} alt={user?.name} />
      <h2>{user?.name}</h2>
      <p>{user?.email}</p>
      <button onClick={() => refetch()}>Refresh</button>
    </div>
  );
}

Example 2: Posts List with Pagination

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

interface PostsResponse {
  posts: Post[];
  pagination: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

function PostsList() {
  const [page, setPage] = useState(1);
  const [allPosts, setAllPosts] = useState<Post[]>([]);

  const {
    data: response,
    loading,
    error,
  } = useApi<PostsResponse>(`/api/posts?page=${page}&limit=10`, {
    cacheKey: `posts-page-${page}`,
    cacheTime: 2 * 60 * 1000, // 2 minutes cache
    onSuccess: (data) => {
      if (page === 1) {
        setAllPosts(data.posts);
      } else {
        setAllPosts((prev) => [...prev, ...data.posts]);
      }
    },
  });

  const loadMore = () => {
    if (response && page < response.pagination.totalPages) {
      setPage((prev) => prev + 1);
    }
  };

  return (
    <div className="posts-list">
      {allPosts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}

      {loading && <PostsSkeleton />}
      {error && <ErrorMessage error={error} />}

      {response && page < response.pagination.totalPages && (
        <button onClick={loadMore} disabled={loading}>
          Load More
        </button>
      )}
    </div>
  );
}

Example 3: Search with Debouncing

import { useMemo } from 'react';
import { useDebounce } from './useDebounce'; // Custom debounce hook

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

function SearchComponent() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  const searchUrl = useMemo(() => {
    return debouncedQuery.trim()
      ? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
      : null;
  }, [debouncedQuery]);

  const {
    data: results,
    loading,
    error,
  } = useApi<SearchResult[]>(searchUrl, {
    skip: !debouncedQuery.trim(),
    cacheKey: `search-${debouncedQuery}`,
    cacheTime: 30 * 1000, // 30 seconds cache for search results
  });

  return (
    <div className="search-component">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />

      {loading && <div>Searching...</div>}
      {error && <div>Search error: {error}</div>}

      {results && (
        <div className="search-results">
          {results.map((result) => (
            <SearchResultItem key={result.id} result={result} />
          ))}
        </div>
      )}
    </div>
  );
}

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

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

Testing Your Custom Hook

Testing custom hooks requires special consideration. Here's how to test our useApi hook:

Setting Up Tests

npm install --save-dev @testing-library/react-hooks @testing-library/jest-dom

Basic Hook Tests

import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';

// Mock fetch
global.fetch = jest.fn();

describe('useApi', () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it('should fetch data successfully', async () => {
    const mockData = { id: 1, name: 'John Doe' };
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    });

    const { result } = renderHook(() => useApi('/api/users/1'));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
  });

  it('should handle errors correctly', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: false,
      status: 404,
    });

    const { result } = renderHook(() => useApi('/api/users/999'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe('HTTP error! status: 404');
  });

  it('should refetch data when refetch is called', async () => {
    const mockData1 = { id: 1, name: 'John' };
    const mockData2 = { id: 1, name: 'John Updated' };

    (fetch as jest.Mock)
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockData1,
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockData2,
      });

    const { result } = renderHook(() => useApi('/api/users/1'));

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData1);
    });

    result.current.refetch();

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData2);
    });
  });
});

Performance Considerations

Memory Management

// Add cleanup for cache to prevent memory leaks
class ApiCache {
  private cache = new Map<string, CacheEntry<any>>();
  private timers = new Map<string, NodeJS.Timeout>();

  set<T>(key: string, data: T, expiresIn: number = 5 * 60 * 1000): void {
    // Clear existing timer
    const existingTimer = this.timers.get(key);
    if (existingTimer) {
      clearTimeout(existingTimer);
    }

    // Set cache entry
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      expiresIn,
    });

    // Set cleanup timer
    const timer = setTimeout(() => {
      this.cache.delete(key);
      this.timers.delete(key);
    }, expiresIn);

    this.timers.set(key, timer);
  }

  clear(): void {
    // Clear all timers
    this.timers.forEach((timer) => clearTimeout(timer));
    this.timers.clear();
    this.cache.clear();
  }
}

Preventing Unnecessary Renders

import { useMemo, useCallback } from 'react';

function useApi<T = any>(
  url: string | null,
  options: UseApiOptions = {}
): UseApiReturn<T> {
  // Memoize the return object to prevent unnecessary renders
  return useMemo(
    () => ({
      ...state,
      refetch: useCallback(
        (skipCache: boolean = true) =>
          fetchDataWithRetry(url!, {}, skipCache, 0),
        [url]
      ),
      cancel: useCallback(() => {
        if (abortControllerRef.current) {
          abortControllerRef.current.abort();
        }
      }, []),
    }),
    [state, url]
  );
}

Best Practices and Tips

1. Error Boundary Integration

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }: any) {
  return (
    <div role="alert" className="error-fallback">
      <h2>Something went wrong:</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onReset={() => window.location.reload()}
    >
      <UserProfile userId={1} />
    </ErrorBoundary>
  );
}

2. Global Error Handling

// Create a global error handler
const globalErrorHandlers = new Set<(error: string) => void>();

export const addGlobalErrorHandler = (handler: (error: string) => void) => {
  globalErrorHandlers.add(handler);
  return () => globalErrorHandlers.delete(handler);
};

const notifyGlobalErrorHandlers = (error: string) => {
  globalErrorHandlers.forEach((handler) => handler(error));
};

// Use in your hook
function useApi<T = any>(url: string | null, options: UseApiOptions = {}) {
  // ... existing code ...

  const fetchData = async (...args) => {
    try {
      // ... fetch logic ...
    } catch (err) {
      const errorMessage =
        err instanceof Error ? err.message : 'An error occurred';

      // Notify global handlers
      notifyGlobalErrorHandlers(errorMessage);

      if (err instanceof Error && err.name !== 'AbortError') {
        setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
        onError?.(errorMessage);
        throw err;
      }
    }
  };

  // ... rest of hook ...
}

3. Request Interceptors

interface RequestInterceptor {
  request?: (config: RequestInit) => RequestInit | Promise<RequestInit>;
  response?: (response: Response) => Response | Promise<Response>;
  error?: (error: Error) => Error | Promise<Error>;
}

class ApiClient {
  private interceptors: RequestInterceptor[] = [];

  addInterceptor(interceptor: RequestInterceptor) {
    this.interceptors.push(interceptor);
  }

  async fetch(url: string, config: RequestInit = {}): Promise<Response> {
    // Apply request interceptors
    let finalConfig = config;
    for (const interceptor of this.interceptors) {
      if (interceptor.request) {
        finalConfig = await interceptor.request(finalConfig);
      }
    }

    try {
      let response = await fetch(url, finalConfig);

      // Apply response interceptors
      for (const interceptor of this.interceptors) {
        if (interceptor.response) {
          response = await interceptor.response(response);
        }
      }

      return response;
    } catch (error) {
      // Apply error interceptors
      let finalError = error as Error;
      for (const interceptor of this.interceptors) {
        if (interceptor.error) {
          finalError = await interceptor.error(finalError);
        }
      }
      throw finalError;
    }
  }
}

// Usage in your hook
const apiClient = new ApiClient();

// Add auth interceptor
apiClient.addInterceptor({
  request: (config) => ({
    ...config,
    headers: {
      ...config.headers,
      Authorization: `Bearer ${getAuthToken()}`,
    },
  }),
});

// Add retry interceptor for 401s
apiClient.addInterceptor({
  error: async (error) => {
    if (error.message.includes('401')) {
      await refreshToken();
      throw new Error('RETRY_REQUEST');
    }
    throw error;
  },
});

Conclusion

Building a custom React hook for API calls provides numerous benefits:

  1. Reusability: Write once, use everywhere
  2. Consistency: Standardized error handling and loading states
  3. Performance: Built-in caching and request cancellation
  4. Maintainability: Centralized API logic
  5. Type Safety: Full TypeScript support
  6. Testing: Easily testable in isolation

Key Features We Implemented:

  • ✅ Loading states and error handling
  • ✅ Request cancellation with AbortController
  • ✅ Manual refetch capability
  • ✅ TypeScript support with generics
  • ✅ Caching mechanism with TTL
  • ✅ Retry logic with exponential backoff
  • ✅ Stale-while-revalidate pattern
  • ✅ Memory management and cleanup

Next Steps:

  1. Add more HTTP methods (POST, PUT, DELETE)
  2. Implement optimistic updates
  3. Add pagination support
  4. Create mutation hooks
  5. Add GraphQL support
  6. Implement offline capabilities

The hook we've built is production-ready and can handle most API call scenarios in modern React applications. Remember to adapt it based on your specific needs and requirements.

Happy coding! 🚀


Want to see more React tutorials and advanced patterns? Follow me for more in-depth guides on modern React development.