Skip to main content

Building a Reusable useFetch Hook for Data Fetching

Fetching data from APIs is a fundamental task in modern React applications, but the logic—managing loading states, handling errors, updating state—gets repeated in many components. A custom useFetch hook encapsulates this repetitive logic into a single, reusable function that returns data, loading status, and error state. This hook reduces boilerplate code, improves consistency across your application, and makes components focused on rendering rather than data management.

Key Takeaways

  • The useFetch hook combines useState to manage data/loading/error state and useEffect to trigger the fetch when the URL changes
  • Wrapping the fetch in an async function inside the effect allows you to use try...catch...finally to handle responses, errors, and the loading state
  • The hook returns an object containing data, loading, and error, which the calling component destructures and uses
  • By extracting fetch logic into a hook, you avoid repeating the same pattern across dozens of components

The Problem: Repetitive Data Fetching Logic

Every time you fetch data in a React component, you repeat the same steps:

  1. Create state for the data itself
  2. Create state for the loading indicator
  3. Create state for error handling
  4. Use useEffect to fetch data when the component mounts or when a dependency changes
  5. Wrap the fetch in try...catch...finally to handle success, errors, and completion
  6. Return or display the data, loading message, or error message

When you need to fetch in 10 different components, you write this logic 10 times. This violation of the DRY (Don't Repeat Yourself) principle introduces maintenance burden and the risk of inconsistency. Custom hooks solve this by extracting the pattern into a reusable function.

Building the useFetch Hook

Create a new file useFetch.js in your project's hooks directory:

import { useState, useEffect } from 'react';

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);
setError(null); // Clear error on success
} catch (err) {
setError(err);
setData(null); // Clear data on error
} finally {
setLoading(false); // Stop loading indicator
}
};

// Only fetch if URL is provided
if (url) {
fetchData();
}
}, [url]); // Re-fetch if URL changes

return { data, loading, error };
}

export default useFetch;

Code Breakdown

State Variables:

  • data: Holds the fetched JSON response (initially null)
  • loading: Tracks whether the fetch is in progress (initially true)
  • error: Holds any error that occurs during fetching (initially null)

The Effect Function:

The useEffect hook runs after component render. Inside it, we define an async function fetchData that performs the actual fetch:

  1. Fetch Request: await fetch(url) sends the HTTP request and waits for the response
  2. Status Check: if (!response.ok) checks if the status code is 2xx. If not (e.g., 404, 500), we throw an error
  3. Parse JSON: await response.json() converts the response body to JavaScript
  4. Set Data: setData(json) updates state with the fetched data
  5. Error Handling: The catch block handles network errors, parsing errors, and the status errors we throw
  6. Clear Error on Success: When data loads successfully, we set error to null to clear any previous errors
  7. Finally Block: The finally block always runs, setting loading to false to indicate the fetch is complete

Dependency Array:

The [url] dependency array ensures the effect re-runs if the URL changes. This allows the same component to fetch different data based on a URL prop.

Guard Clause:

The if (url) check prevents fetching if no URL is provided, avoiding unnecessary fetch attempts.

Return Value:

The hook returns an object containing all three state variables, allowing the calling component to access data, loading, and error.

Using useFetch in Components

Here's a practical example: fetching and displaying a list of users from a JSON API:

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

function UserList() {
const { data: users, loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/users'
);

// Show loading message
if (loading) {
return <div className="loading">Fetching users...</div>;
}

// Show error message
if (error) {
return <div className="error">Error: {error.message}</div>;
}

// Show data
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>
<strong>{user.name}</strong>
<p>{user.email}</p>
</li>
))}
</ul>
</div>
);
}

export default UserList;

Component Breakdown:

  1. Destructuring with Rename: const { data: users, loading, error } extracts the three values from the hook. We rename data to users for clarity.

  2. Loading State: If loading is true, show a loading message. The user sees this immediately while the fetch is in progress.

  3. Error State: If an error occurred, display the error message. This might be a network failure, server error, or parsing error.

  4. Rendered Data: If loading is false and no error occurred, the data is ready. Map over the users array and render each user's name and email.

Real-World Example: Dynamic URL Fetching

The power of useFetch becomes clear when the URL changes dynamically:

import React, { useState } from 'react';
import useFetch from './useFetch';

function PostViewer() {
const [postId, setPostId] = useState(1);

// useFetch will re-run whenever postId changes
const { data: post, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`
);

const handleNextPost = () => setPostId(prev => prev + 1);
const handlePrevPost = () => setPostId(prev => Math.max(1, prev - 1));

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

return (
<div>
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>

<nav>
<button onClick={handlePrevPost} disabled={postId === 1}>
Previous Post
</button>
<span>Post {postId}</span>
<button onClick={handleNextPost}>Next Post</button>
</nav>
</div>
);
}

export default PostViewer;

As the user clicks "Next Post" or "Previous Post," postId changes, the URL changes, and the useEffect dependency array detects the URL change and re-runs the fetch. This automatically loads the new post without the component needing to manually trigger the fetch.

Advanced Pattern: Conditional Fetching

Sometimes you don't want to fetch immediately. You can add a condition to the hook:

import React, { useState } from 'react';
import useFetch from './useFetch';

function SearchUsers() {
const [query, setQuery] = useState('');
const [shouldFetch, setShouldFetch] = useState(false);

// Only fetch if shouldFetch is true
const fetchUrl = shouldFetch && query
? `https://api.example.com/users?search=${query}`
: null;

const { data: results, loading, error } = useFetch(fetchUrl);

const handleSearch = () => {
if (query.trim()) setShouldFetch(true);
};

return (
<div>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search users..."
/>
<button onClick={handleSearch}>Search</button>

{loading && <p>Searching...</p>}
{error && <p>Error: {error.message}</p>}
{results && (
<ul>
{results.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}

export default SearchUsers;

By passing null as the URL when shouldFetch is false, the hook skips the fetch entirely. This is useful for search forms where you only want to fetch when the user explicitly clicks the search button.

Handling Different Response Formats

The basic useFetch hook assumes JSON responses. If your API returns different formats, extend the hook:

function useFetch(url, options = {}) {
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, options);

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

// Handle different content types
const contentType = response.headers.get('content-type');
let json;

if (contentType?.includes('application/json')) {
json = await response.json();
} else if (contentType?.includes('text/')) {
json = await response.text();
} else {
json = await response.blob();
}

setData(json);
setError(null);
} catch (err) {
setError(err);
setData(null);
} finally {
setLoading(false);
}
};

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

return { data, loading, error };
}

Frequently Asked Questions

What if the component unmounts while a fetch is in progress?

The fetch continues in the background, but when it completes, React prevents the state update (warning: "can't perform a React state update on an unmounted component"). Future articles will cover the AbortController API to properly cancel requests and prevent memory leaks.

Can I use useFetch with multiple URLs in one component?

Yes, call the hook multiple times:

const { data: users } = useFetch('/api/users');
const { data: posts } = useFetch('/api/posts');

Each call is independent, managing its own state and effect.

How do I pass fetch options like headers or method?

Modify the hook to accept an options parameter:

function useFetch(url, options = {}) {
// ... inside the effect ...
const response = await fetch(url, options);

Then call it with options: useFetch(url, { method: 'POST', headers: {...} })

Should I put useFetch in every component that fetches?

Yes, it's the idiomatic React pattern. Extract repetitive logic into hooks to keep components focused on rendering and making hook calls. The hook handles the complexity.

Glossary

Custom Hook: A JavaScript function in React that uses other hooks to extract and reuse stateful logic across components. Custom hooks must start with the word use.

useFetch: A custom hook that manages the logic of fetching data from a URL: state for data, loading, and error, plus the effect that performs the fetch.

Fetch API: A modern browser API for making HTTP requests without needing a library. Returns a Promise that resolves to a Response object.

Async/Await: Modern JavaScript syntax for writing asynchronous code that reads like synchronous code. async marks a function as asynchronous; await pauses execution until a Promise resolves.

Dependency Array: The optional second argument to useEffect that tells React when to re-run the effect. If the URL changes, the effect re-runs to fetch the new data.

Further Reading