DEV Community

Cover image for 🚀 Build a Production-Ready React Data Fetching Hook with TypeScript
Mayank vishwakarma
Mayank vishwakarma

Posted on

🚀 Build a Production-Ready React Data Fetching Hook with TypeScript

Data fetching is a fundamental part of modern web applications, but managing loading states, errors, retries, and cleanup can be complex. In this post, we'll dive deep into useFetcher, a powerful custom React hook that simplifies data fetching while providing advanced features like automatic retries, abort handling, and cleanup.

The Problem

When fetching data in React components, we often need to handle several concerns:

  • Loading states
  • Error handling
  • Automatic retries
  • Cleanup on unmount
  • Aborting ongoing requests
  • Periodic refetching
  • Memory leaks

Managing all these aspects manually can lead to verbose, error-prone code. Let's see how we can solve this with a custom hook.

Introducing useFetcher

useFetcher is a custom hook that encapsulates all these concerns into a clean, reusable interface. Here's a basic example of how to use it:

const { data, loading, error, refetch } = useFetcher(
  async () => {
    const response = await fetch('/api/users');
    const data = await response.json();
    return { data };
  },
  { fetchOnLoad: true }
);
Enter fullscreen mode Exit fullscreen mode

Key Features

1. Type Safety

The hook is fully typed using TypeScript generics:

type FetcherResponse<T> = {
  data: T;
};

interface FetcherOptions<T> {
  fetchOnLoad?: boolean;
  retryIntervalInSec?: number;
  initialData?: T;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  enabled?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

This ensures type safety throughout your application and provides excellent IDE support.

2. Automatic Loading States

The hook manages loading states internally, providing a boolean loading flag that you can use to show loading indicators:

const { loading } = useFetcher(fetchData);
return loading ? <LoadingSpinner /> : <DataDisplay />;
Enter fullscreen mode Exit fullscreen mode

3. Error Handling

Errors are caught and normalized into a consistent format:

const { error } = useFetcher(fetchData, {
  onError: (error) => {
    console.error('Fetch failed:', error);
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Automatic Retries

You can enable automatic retrying with a specified interval:

const { data } = useFetcher(fetchData, {
  retryIntervalInSec: 30  // Retry every 30 seconds
});
Enter fullscreen mode Exit fullscreen mode

5. Abort Control

The hook automatically handles request abortion when the component unmounts or when a new request is initiated:

const { refetch } = useFetcher(fetchData);
// Calling refetch() will abort any ongoing request
// and start a new one
Enter fullscreen mode Exit fullscreen mode

Advanced Usage

Conditional Fetching

You can control when the fetcher is enabled:

const { data } = useFetcher(fetchUserData, {
  enabled: !!userId,  // Only fetch when userId is available
  fetchOnLoad: true
});
Enter fullscreen mode Exit fullscreen mode

Success and Error Callbacks

Handle success and error cases with callbacks:

const { data } = useFetcher(fetchData, {
  onSuccess: (data) => {
    toast.success('Data loaded successfully!');
  },
  onError: (error) => {
    toast.error(`Failed to load: ${error.message}`);
  }
});
Enter fullscreen mode Exit fullscreen mode

Complete Implementation

Here's the full implementation of the useFetcher hook:

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

type FetcherResponse<T> = {
  data: T;
};

interface FetcherOptions<T> {
  fetchOnLoad?: boolean;
  retryIntervalInSec?: number;
  initialData?: T;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  enabled?: boolean;
}

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

export default function useFetcher<T>(
  callback: (signal?: AbortSignal) => Promise<FetcherResponse<T>>,
  options: FetcherOptions<T> = {}
) {
  const {
    fetchOnLoad = false,
    retryIntervalInSec,
    onSuccess,
    onError,
    enabled = true,
  } = options;

  const intervalRef = useRef<ReturnType<typeof setInterval>>();
  const abortControllerRef = useRef<AbortController>();
  const isMountedRef = useRef(true);

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

  const fetcher = useCallback(
    async (signal?: AbortSignal) => {
      if (!enabled) return;

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

      try {
        const response = await callback(signal);
        if (!isMountedRef.current) return;

        setState({
          data: response.data,
          error: null,
          loading: false,
        });

        onSuccess?.(response.data);
      } catch (error: any) {
        if (error.name === 'AbortError') return;
        if (!isMountedRef.current) return;

        const errorObject = error instanceof Error ? error : new Error(
          error?.message || 'Unknown error'
        );

        if ('status' in error) {
          (errorObject as any).status = error.status;
        }

        setState((prev) => ({
          ...prev,
          error: errorObject,
          loading: false,
        }));

        onError?.(errorObject);
      }
    },
    [callback, enabled, onSuccess, onError]
  );

  useEffect(() => {
    if (
      fetchOnLoad &&
      enabled &&
      !state.data &&
      !state.error &&
      !state.loading
    ) {
      fetcher();
    }
  }, [fetchOnLoad, enabled, state.data, state.error, state.loading, fetcher]);

  useEffect(() => {
    if (enabled && retryIntervalInSec && !state.error) {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }

      intervalRef.current = setInterval(fetcher, retryIntervalInSec * 1000);
    }

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [enabled, retryIntervalInSec, state.error, fetcher]);

  useEffect(() => {
    return () => {
      isMountedRef.current = false;
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

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

    abortControllerRef.current = new AbortController();
    return fetcher(abortControllerRef.current.signal);
  }, [fetcher]);

  return {
    ...state,
    refetch,
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage Examples

Let's look at some real-world examples of how to use this hook:

Basic Usage

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useFetcher<User>(
    async () => {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      return { data };
    },
    { fetchOnLoad: true }
  );

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;

  return <div>Hello, {data.name}!</div>;
}
Enter fullscreen mode Exit fullscreen mode

With Auto-Retry

function LiveDataFeed() {
  const { data } = useFetcher<LiveData>(
    async () => {
      const response = await fetch('/api/live-feed');
      const data = await response.json();
      return { data };
    },
    {
      fetchOnLoad: true,
      retryIntervalInSec: 30,
      onError: (error) => {
        console.error('Failed to fetch live data:', error);
      }
    }
  );

  return <div>{data?.latestValue}</div>;
}
Enter fullscreen mode Exit fullscreen mode

With Abort Control

function SearchResults({ query }: { query: string }) {
  const { data, refetch } = useFetcher<SearchResult[]>(
    async (signal) => {
      const response = await fetch(`/api/search?q=${query}`, { signal });
      const data = await response.json();
      return { data };
    },
    { enabled: query.length > 0 }
  );

  useEffect(() => {
    refetch();
  }, [query, refetch]);

  return <ResultsList results={data || []} />;
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Always provide error handling: Use the onError callback or handle the error state in your UI.
  2. Be mindful of retry intervals: Choose appropriate retry intervals based on your use case.
  3. Clean up resources: The hook handles cleanup automatically, but be aware of any additional cleanup needed in your fetch callback.
  4. Use TypeScript: Take advantage of the type safety provided by the hook.

Conclusion

The useFetcher hook provides a robust solution for handling data fetching in React applications. With its TypeScript support, automatic cleanup, and advanced features like retries and abort control, it can significantly simplify your data fetching logic while maintaining good practices and avoiding common pitfalls.

Feel free to use this hook in your projects and adapt it to your specific needs. The complete implementation is available above, and you can modify it to add more features or adjust the behavior to match your requirements.

Happy Coding 😊!

Top comments (0)