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 benullorundefined.loading: A boolean indicating whether the fetch request is in progress. Initially, it should betrue.error: This state will hold any error object encountered during the fetch process. Initially, it should benull.
- API Return: The hook should return an object containing
data,loading, anderror. - 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:
- When a component using
useFetchmounts,loadingshould betrue, anddataanderrorshould benull. - If the fetch is successful,
datashould be updated with the response,loadingshould becomefalse, anderrorshould remainnull. - If the fetch fails,
errorshould be updated with the error object,loadingshould becomefalse, anddatashould remainnull. - 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
fetchAPI. - 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 indatabeing[], notnull. - Consider the case where the initial
urlpassed touseFetchmight be an empty string ornull/undefined. Your hook should handle this gracefully (e.g., by not fetching).
Notes
- Think about how to handle the
AbortControllerfor effective cleanup. - The
useEffecthook will be crucial for managing the side effect of data fetching. - Consider the initial state of
dataand 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.