Hone logo
Hone
Problems

Custom React Hook: useFetch

This challenge focuses on building a reusable custom React hook, useFetch, for simplifying data fetching in TypeScript applications. A well-implemented useFetch hook can abstract away common logic like managing loading states, error handling, and storing fetched data, making your components cleaner and more maintainable.

Problem Description

Your task is to create a custom React hook named useFetch that abstracts the process of fetching data from a given URL. This hook should manage the loading, error, and data states internally and return them to the component using the hook.

Key requirements:

  • Fetch Data: The hook should accept a URL string as an argument and initiate a fetch request when the component mounts.
  • State Management: The hook must maintain three states:
    • data: This state will hold the fetched data. It should be typed generically to accommodate any data structure. Initially, it should be null or undefined.
    • loading: A boolean indicating whether the fetch request is in progress. Initially, it should be true.
    • error: This state will hold any error object encountered during the fetch process. Initially, it should be null.
  • API Return: The hook should return an object containing data, loading, and error.
  • Cleanup: Implement a mechanism to cancel ongoing fetch requests if the component unmounts before the request completes, preventing potential memory leaks and race conditions.
  • Dependency Array: The hook should re-fetch data if the provided URL changes.

Expected behavior:

  1. When a component using useFetch mounts, loading should be true, and data and error should be null.
  2. If the fetch is successful, data should be updated with the response, loading should become false, and error should remain null.
  3. If the fetch fails, error should be updated with the error object, loading should become false, and data should remain null.
  4. If the component unmounts while fetching, the request should be aborted.

Examples

Example 1: Fetching a list of users

// App.tsx
import React from 'react';
import useFetch from './useFetch';

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

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

  if (loading) {
    return <div>Loading users...</div>;
  }

  if (error) {
    return <div>Error fetching users: {error.message}</div>;
  }

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

export default UserList;
// useFetch.ts
import { useState, useEffect } from 'react';

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

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

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

    const fetchData = async () => {
      setLoading(true);
      setError(null); // Reset error on new fetch
      try {
        const response = await fetch(url, { signal });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        if ((err as Error).name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          setError(err as Error);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => {
      controller.abort(); // Abort fetch on cleanup
    };
  }, [url]); // Re-fetch if URL changes

  return { data, loading, error };
}

export default useFetch;

Example 2: Fetching a single post with a different URL

// PostDetail.tsx
import React from 'react';
import useFetch from './useFetch';

interface Post {
  id: number;
  title: string;
  body: string;
}

function PostDetail({ postId }: { postId: number }) {
  const { data: post, loading, error } = useFetch<Post>(`/api/posts/${postId}`);

  if (loading) {
    return <div>Loading post...</div>;
  }

  if (error) {
    return <div>Error fetching post: {error.message}</div>;
  }

  return (
    <div>
      <h2>{post?.title}</h2>
      <p>{post?.body}</p>
    </div>
  );
}

export default PostDetail;

Example 3: Handling a URL that results in an error

Imagine /api/nonexistent returns a 404 error.

// ErrorComponent.tsx
import React from 'react';
import useFetch from './useFetch';

interface Data {
  message: string;
}

function ErrorComponent() {
  const { data, loading, error } = useFetch<Data>('/api/nonexistent');

  if (loading) {
    return <div>Attempting to fetch...</div>;
  }

  if (error) {
    return <div>An error occurred: {error.message}</div>; // Expected: "An error occurred: HTTP error! status: 404"
  }

  return <div>Data fetched successfully: {data?.message}</div>;
}

export default ErrorComponent;

Constraints

  • The hook must be written in TypeScript.
  • The hook should utilize the browser's native fetch API.
  • Error handling should catch network errors and non-OK HTTP responses (e.g., 404, 500).
  • The hook should be generic (useFetch<T>) to allow type safety for the fetched data.
  • A successful fetch of an empty array [] should result in data being [], not null.
  • Consider the case where the initial url passed to useFetch might be an empty string or null/undefined. Your hook should handle this gracefully (e.g., by not fetching).

Notes

  • Think about how to handle the AbortController for effective cleanup.
  • The useEffect hook will be crucial for managing the side effect of data fetching.
  • Consider the initial state of data and how to differentiate between "no data yet" and "fetched an empty array".
  • Ensure your error handling differentiates between an AbortError (intentional cancellation) and other network/HTTP errors.
Loading editor...
typescript