The Case for Custom Hooks: Readability and Reusability
Today, let's talk about custom hooks in React. Custom hooks are essentially helper functions that utilize built-in hooks. They enable us to create reusable pieces of logic shared across components, enhancing code readability and testability by encapsulating related functionality.
A common pattern in React is to fetch data after the component has mounted using a useEffect. This often involves managing three pieces of state: data, loading, and error states. Here's how it typically looks:
import React, { useState, useEffect } from 'react';
type DataType = {
id: number;
name: string;
// Whatever else
};
export function DataFetchingComponent() {
const [data, setData] = useState<DataType[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data: DataType[] = await response.json();
setData(data);
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, []); // Empty dependency array means this effect runs once on mount
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
{data ? (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
) : (
<div>No data available</div>
)}
</div>
);
}
While this component is fully functional, we might find ourselves repeating this logic across our application. How can we improve this? Enter custom hooks.
import { useState, useEffect } from 'react';
type UseFetchDataResult<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
export function useFetch<T>(url: string): UseFetchDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result: T = await response.json();
setData(result);
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
Here, we've extracted our stateful logic into a custom hook. We can now use this hook in our original component or any other component that follows the same data fetching pattern:
import React from 'react';
import useFetch from '@/utils/hooks/useFetch';
type DataType = {
id: number;
name: string;
// Whatever else
};
export function DataFetchingComponent() {
const { data, loading, error } = useFetch<DataType[]>('https://api.example.com/data');
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
{data ? (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
) : (
<div>No data available</div>
)}
</div>
);
}
This approach drastically improves readability, and the next time we need to write a similar component, we won't need to copy and paste all of that boilerplate. Custom hooks are invaluable for reusable logic like this, especially in complex components with multiple states. They clarify each piece of state and greatly simplify testing.
Cheers,
NHC