In modern React development, custom hooks have become a powerful way to simplify and reuse logic across multiple components.
If you’ve already explored the core React Hooks like useState and useEffect, you know how they make managing state and side effects easier. But when you find yourself repeating the same logic — especially for fetching data from APIs — it’s time to wrap that logic into a custom hook.
In this article, you’ll learn:
Why custom hooks are essential.
How to create a reusable API-fetching hook.
How to handle loading, errors, and refetching.
When to prefer custom hooks over third-party solutions.
Let’s get started.
What Is a Custom Hook?
A custom hook is simply a JavaScript function whose name starts with "use" and that can call other React hooks inside it.
It lets you extract reusable logic from components — so you can keep your code clean and DRY (Don’t Repeat Yourself).
For example, if multiple components in your app are fetching data from APIs, you can move that logic into a useFetch hook and reuse it anywhere.
Building a Basic Custom Hook: useFetch
Let’s create a simple custom hook for fetching data from any given API URL.
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(() => {
let isMounted = true; // to prevent state updates on unmounted component
async function fetchData() {
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Network response was not ok");
const json = await response.json();
if (isMounted) {
setData(json);
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err);
setLoading(false);
}
}
}
fetchData();
return () => (isMounted = false); // cleanup
}, [url]);
return { data, loading, error };
}
This custom hook:
Accepts a url parameter.
Fetches data asynchronously.
Returns an object with { data, loading, error }.
You can use it across your entire app without rewriting fetch logic again and again.
⚙️ Using the useFetch Hook Inside Components
Here’s how you can use the useFetch hook in a component:
import React from "react";
import { useFetch } from "./useFetch";
function Users() {
const { data: users, loading, error } = useFetch("https://jsonplaceholder.typicode.com/users");
if (loading) return <p>Loading users...</p>;
if (error) return <p>Error loading data: {error.message}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
export default Users;
Notice how simple the component now looks — all the fetching, error handling, and cleanup logic are tucked neatly away in the custom hook.
This kind of logic abstraction is similar to how React’s built-in Hooks like useEffect and useState separate responsibilities.
Adding Refetch Capability
Sometimes, you might need to refetch the data on user action (like a refresh button). Let’s add that feature.
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [reloadFlag, setReloadFlag] = useState(false);
useEffect(() => {
async function fetchData() {
setLoading(true);
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Network Error");
const json = await response.json();
setData(json);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchData();
}, [url, reloadFlag]);
const refetch = () => setReloadFlag(prev => !prev);
return { data, loading, error, refetch };
}
Now you can manually trigger a refetch in your component:
const { data, loading, error, refetch } = useFetch(API_URL);
<button onClick={refetch}>Reload Data</button>;
Why Custom Hooks Are Powerful
Reusability: You can use the same logic across different components.
Separation of Concerns: Components stay focused on rendering, not data handling.
Cleaner Code: Reduced duplication improves readability and maintainability.
Easy Testing: Hooks are just functions — easy to isolate and test.
For instance, you could combine useFetch with context from the useContext Hook for global state management to share API data across multiple components.
💡 Handling Complex APIs with Axios
You can enhance your hook to support Axios (a popular HTTP client) for easier configuration and interceptors.
import axios from "axios";
export function useAxios(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancel;
axios
.get(url, {
cancelToken: new axios.CancelToken(c => (cancel = c))
})
.then(res => {
setData(res.data);
setLoading(false);
})
.catch(err => {
if (axios.isCancel(err)) return;
setError(err);
setLoading(false);
});
return () => cancel();
}, [url]);
return { data, error, loading };
}
This approach works perfectly for apps where you need token-based authentication or interceptors — features supported natively by Axios.
Expanding the Custom Hook: Adding Config Options
To make your hook more flexible, you can allow custom options for requests (e.g., POST, headers).
export function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function fetchData() {
try {
const res = await fetch(url, options);
const result = await res.json();
if (!ignore) setData(result);
} catch (err) {
if (!ignore) setError(err);
} finally {
if (!ignore) setLoading(false);
}
}
fetchData();
return () => (ignore = true);
}, [url]);
return { data, error, loading };
}
You can now call it like:
useApi("/api/users", { method: "POST", body: JSON.stringify(userData) });
When to Use Third-Party Hook Libraries
While custom hooks are great, they have limitations — like manual caching and refetching logic.
When your app grows, you might want to look into third-party libraries such as:
React Query (handles caching, retries, background updates)
SWR by Vercel (simple data fetching with built-in cache)
usehooks-ts (collection of reusable hooks for common needs)
Common Mistakes to Avoid
Not cleaning up fetches: Always handle component unmounts to avoid memory leaks.
Hardcoding URLs: Make your hook flexible by accepting URLs or configs.
Skipping error handling: Network errors are inevitable; handle them gracefully.
Updating state after unmount: Always track mount state or use AbortController.
If you’re still learning about effect cleanup, revisit how we used it in Understanding useState and useEffect: The Core of React Hooks — it’s the same principle.
Real-World Example: Fetching Products in an E-commerce App
function Products() {
const { data: products, loading, error, refetch } = useFetch("/api/products");
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong.</p>;
return (
<div>
<h2>Product List</h2>
<button onClick={refetch}>Refresh</button>
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
This pattern is scalable — you can easily use the same hook to fetch users, orders, or dashboard stats with just one line change.
Key Takeaways
Custom hooks encapsulate logic and promote reusability.
Always include error and loading states.
Refetching and cleanup make your hook production-ready.
For advanced use cases, try libraries like React Query or SWR.
Once you master this pattern, managing data becomes a breeze.
Conclusion
Creating your own custom hook for API calls is one of the best ways to understand how React Hooks truly work together.
It’s a practical blend of useState and useEffect, wrapped in a reusable function — making your components lightweight and maintainable.
As you progress, you’ll often need to share fetched data globally across components, like user profiles or theme settings. That’s where the useContext Hook comes in — allowing global state management without heavy libraries like Redux.
Comments
Post a Comment