Building a Reusable Data-Fetching Hook
React components frequently need to pull data from an API when they mount or when their props or state change. Before Hooks, that meant wiring up componentDidMount() for the initial request and componentDidUpdate() for subsequent changes, as seen in the classic class-based pattern:
componentDidMount() {
const fetchData = async () => {
const response = await fetch(
`https://hn.algolia.com/api/v1/search?query=JavaScript`
);
const data = await response.json();
this.setState({ data });
};
fetchData();
}
componentDidUpdate(previousProps, previousState) {
if (previousState.query !== this.state.query) {
const fetchData = async () => {
const response = await fetch(
`https://hn.algolia.com/api/v1/search?query=${this.state.query}`
);
const data = await response.json();
this.setState({ data });
};
fetchData();
}
}
The componentDidUpdate method in particular required guard clauses to compare previous and current state values, preventing unnecessary network calls each time the component re-rendered. Hooks consolidate this into a single useEffect call that runs on mount and whenever its dependency array changes:
import { useState, useEffect } from 'react';
const [status, setStatus] = useState('idle');
const [query, setQuery] = useState('');
const [data, setData] = useState([]);
useEffect(() => {
if (!query) return;
const fetchData = async () => {
setStatus('fetching');
const response = await fetch(
`https://hn.algolia.com/api/v1/search?query=${query}`
);
const data = await response.json();
setData(data.hits);
setStatus('fetched');
};
fetchData();
}, [query]);
Passing query as a dependency ensures the effect only re-runs when the search term actually changes. The component also tracks a status field to communicate distinct states to the user: idle before any search, fetching while the request is in flight (where a spinner is typically shown), and fetched once data arrives. When updating state, it's important to set the data before flipping the status to fetched, otherwise a brief flicker can occur where the status says loaded but the data is still empty.
Extracting a Custom Hook
The React documentation defines a custom hook as a JavaScript function whose name starts with "use" and that may call other Hooks. This simple contract enables code reuse across an application. A minimal counter hook demonstrates the idea:
const useCounter = (initialState = 0) => {
const [count, setCount] = useState(initialState);
const add = () => setCount(count + 1);
const subtract = () => setCount(count - 1);
return { count, add, subtract };
};
Consuming the hook is straightforward:
import { useCounter } from './customHookPath';
const { count, add, subtract } = useCounter(100);
eventHandler(() => {
add(); // or subtract();
});
The hook accepts an optional initial value, defaults it to 0, and returns the current count along with add and subtract methods for updating it.
A Generic useFetch Hook
The same extraction principle applies to data fetching. The initial fetch logic can be pulled into a custom hook that accepts a query and returns status and data:
const useFetch = (query) => {
const [status, setStatus] = useState('idle');
const [data, setData] = useState([]);
useEffect(() => {
if (!query) return;
const fetchData = async () => {
setStatus('fetching');
const response = await fetch(
`https://hn.algolia.com/api/v1/search?query=${query}`
);
const data = await response.json();
setData(data.hits);
setStatus('fetched');
};
fetchData();
}, [query]);
return { status, data };
};
That version is tied to the Hacker News API. To make it work with any URL, the hook should accept a full endpoint string instead:
const useFetch = (url) => {
const [status, setStatus] = useState('idle');
const [data, setData] = useState([]);
useEffect(() => {
if (!url) return;
const fetchData = async () => {
setStatus('fetching');
const response = await fetch(url);
const data = await response.json();
setData(data);
setStatus('fetched');
};
fetchData();
}, [url]);
return { status, data };
};
Consumers then pass in a dynamically constructed URL. For instance, a component might only issue a request when a query value is truthy, passing undefined otherwise since the hook handles that case gracefully:
const [query, setQuery] = useState('');
const url = query && `https://hn.algolia.com/api/v1/search?query=${query}`;
const { status, data } = useFetch(url);
Caching Responses with useRef
To avoid redundant network calls for data already fetched, a cache can map URLs to their responses. A naive implementation might declare a cache object outside the hook. While that works, it violates the principle of a pure function and leaves the data lingering globally. React's useRef hook provides a better container: it holds a mutable value in its .current property that persists for the component's lifecycle.
Placing a cache object inside the hook with useRef gives us a per-instance data store:
const useFetch = (url) => {
const cache = useRef({});
const [status, setStatus] = useState('idle');
const [data, setData] = useState([]);
useEffect(() => {
if (!url) return;
const fetchData = async () => {
setStatus('fetching');
if (cache.current[url]) {
const data = cache.current[url];
setData(data);
setStatus('fetched');
} else {
const response = await fetch(url);
const data = await response.json();
cache.current[url] = data; // set response in cache;
setData(data);
setStatus('fetched');
}
};
fetchData();
}, [url]);
return { status, data };
};
On each call, the hook first checks the cache. If the URL already has a stored result, it returns immediately; otherwise it performs the fetch and stores the result. The effect also returns early when the URL is falsy, preventing requests for non-existent endpoints. This check must be inside the effect body rather than before it, as Hooks rules require calling Hooks at the top level of the function.
Refining State with useReducer
Setting data before status can lead to two issues: unit tests may fail because the data array isn't empty while in the fetching state, and React can't reliably batch these asynchronous state changes, causing unnecessary re-renders. Switching from separate useState calls to a single useReducer addresses both concerns:
const initialState = {
status: 'idle',
error: null,
data: [],
};
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'FETCHING':
return { ...initialState, status: 'fetching' };
case 'FETCHED':
return { ...initialState, status: 'fetched', data: action.payload };
case 'FETCH_ERROR':
return { ...initialState, status: 'error', error: action.payload };
default:
return state;
}
}, initialState);
The reducer uses an initial state to capture all the fields previously managed by individual useState declarations. Depending on the dispatched action type, it updates both the status and the data simultaneously. This prevents impossible intermediate states and minimizes re-renders.
Cleaning Up the Effect
Fetch requests run asynchronously via Promises. If a component unmounts before the Promise resolves, React will warn about updating state on an unmounted component. Adding a cleanup function inside useEffect prevents this:
useEffect(() => {
let cancelRequest = false;
if (!url) return;
const fetchData = async () => {
dispatch({ type: 'FETCHING' });
if (cache.current[url]) {
const data = cache.current[url];
dispatch({ type: 'FETCHED', payload: data });
} else {
try {
const response = await fetch(url);
const data = await response.json();
cache.current[url] = data;
if (cancelRequest) return;
dispatch({ type: 'FETCHED', payload: data });
} catch (error) {
if (cancelRequest) return;
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}
};
fetchData();
return function cleanup() {
cancelRequest = true;
};
}, [url]);
Inside the effect, a cancelRequest flag is set to true and returned as the cleanup function. Before any state update, the code checks this flag. If the component has unmounted, the state update is skipped; otherwise it proceeds normally. This eliminates the stale update warning and also guards against race conditions where an older request resolves after a newer one.



