Custom Hooks for API Calls and Caching

Loading

Creating custom hooks for API calls with caching capabilities is a powerful way to manage server state in React applications. Here’s a comprehensive guide to building robust API hooks with caching, error handling, and more.

Basic API Hook

1. Simple Fetch Hook

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

// Usage
const { data, loading, error } = useFetch('/api/users');

Advanced API Hook with Caching

2. Hook with Local Storage Caching

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        // Check cache first
        const cachedData = localStorage.getItem(cacheKey);
        if (cachedData) {
          setData(JSON.parse(cachedData));
          setLoading(false);
        }

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

        const json = await response.json();
        setData(json);
        localStorage.setItem(cacheKey, JSON.stringify(json));
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

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

  return { data, loading, error };
}

// Usage
const { data, loading, error } = useFetchWithCache(
  '/api/users', 
  'users-cache'
);

3. Hook with In-Memory Cache and Refetch

import { useState, useEffect } from 'react';

const cache = new Map();

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

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

      if (!forceRefresh && cache.has(url)) {
        setData(cache.get(url));
        setLoading(false);
        return;
      }

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

      const json = await response.json();
      cache.set(url, json);
      setData(json);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

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

  return { data, loading, error, refetch: () => fetchData(true) };
}

// Usage
const { data, loading, error, refetch } = useApi('/api/users');

Hook with Advanced Features

4. API Hook with Abort Controller

import { useState, useEffect } from 'react';

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

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

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

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

    fetchData();

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

  return { data, loading, error };
}

5. Hook with Request Deduplication

import { useState, useEffect } from 'react';

const pendingRequests = new Map();

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

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

    const fetchData = async () => {
      // Check if request is already pending
      if (pendingRequests.has(url)) {
        const existingRequest = pendingRequests.get(url);
        try {
          const result = await existingRequest;
          if (isMounted) setData(result);
        } catch (err) {
          if (isMounted) setError(err.message);
        } finally {
          if (isMounted) setLoading(false);
        }
        return;
      }

      // Create new request
      const request = fetch(url)
        .then(response => {
          if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
          return response.json();
        })
        .finally(() => {
          pendingRequests.delete(url);
        });

      pendingRequests.set(url, request);

      try {
        const result = await request;
        if (isMounted) setData(result);
      } catch (err) {
        if (isMounted) setError(err.message);
      } finally {
        if (isMounted) setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

TypeScript Implementation

6. Typed API Hook with Cache

import { useState, useEffect } from 'react';

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

export function useApi<T>(url: string): ApiResponse<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  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 json: T = await response.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  };

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

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

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

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

Hook with Optimistic Updates

7. API Hook with Optimistic UI

import { useState } from 'react';

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

  const mutate = async (newData, optimisticUpdate) => {
    try {
      // Apply optimistic update
      if (optimisticUpdate) {
        setData(prev => optimisticUpdate(prev));
      }

      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newData)
      });

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

      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err.message);
      // Revert optimistic update on error
      if (optimisticUpdate) {
        setData(prev => {
          // Implement your rollback logic here
          return prev; // Simplified example
        });
      }
    }
  };

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

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

    fetchData();
  }, [url]);

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

// Usage
const { data, mutate } = useOptimisticApi('/api/todos');

const handleAddTodo = (newTodo) => {
  mutate(newTodo, (prevTodos) => [...prevTodos, { ...newTodo, id: Date.now() }]);
};

Best Practices

  1. Error Handling: Always handle errors gracefully and provide useful error messages
  2. Cleanup: Implement cleanup in useEffect to avoid memory leaks
  3. Dependency Arrays: Include all dependencies to avoid stale closures
  4. Type Safety: Use TypeScript for better developer experience
  5. Testing: Write tests for your hooks to ensure reliability
  6. Performance: Implement caching and request deduplication where appropriate
  7. Abort Controllers: Cancel pending requests when components unmount

Testing Custom Hooks

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

describe('useApi', () => {
  beforeEach(() => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ data: 'test' }),
      })
    );
  });

  it('should fetch data successfully', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useApi('/test'));

    expect(result.current.loading).toBe(true);

    await waitForNextUpdate();

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toEqual({ data: 'test' });
    expect(result.current.error).toBeNull();
  });

  it('should handle errors', async () => {
    global.fetch.mockImplementationOnce(() =>
      Promise.reject(new Error('Network error'))
    );

    const { result, waitForNextUpdate } = renderHook(() => useApi('/test'));

    await waitForNextUpdate();

    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBe('Network error');
  });
});

These custom hooks provide a solid foundation for handling API calls in React applications. You can extend them with additional features like retry logic, offline support, or integration with state management libraries based on your application’s needs.

Leave a Reply

Your email address will not be published. Required fields are marked *