Tutorial: Build a custom useFetch hook
To get started with our custom hook, let's first take a look at how the PokemonList
component would look like if we didn't have a useFetch
hook:
import { useEffect, useState } from "react";
type Pokemon = {
name: string;
};
const PokemonList = () => {
const [pokemons, setPokemons] = useState<Pokemon[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
const fetchPokemonData = async () => {
const params = new URLSearchParams({ limit: "10", offset: "0" });
try {
setIsLoading(true);
const response = await fetch(
`https://pokeapi.co/api/v2/pokemon?${params}`
);
const data = await response.json();
setPokemons(data.results);
setIsLoading(false);
} catch (e) {
const errorMessage = (e as { message: string }).message;
setError(errorMessage);
setIsLoading(false);
}
};
fetchPokemonData();
}, []);
if (isLoading) {
return <p>Loading ...</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<ol>
{pokemons.map((pokemon) => (
<li key={pokemon.name}>{pokemon.name}</li>
))}
</ol>
);
};
export default PokemonList;
Basically, we would just use three useState
hooks, one each for the data
, isLoading
and the error
.
Creating the custom hook
To create our custom hook, we just need to move this logic to the hook function:
import { useEffect, useState } from "react";
const useFetch = (url, options) => {
const [data, setData] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
async function loadData() {
try {
setIsLoading(true);
const response = await fetch(url, options);
const data = await response.json();
setData(data);
setIsLoading(false);
} catch (e) {
setError((e as { message: string }).message);
setIsLoading(false);
}
}
loadData();
}, [url, options]);
return { data, isLoading, error };
};
export { useFetch };
This works, but we get a lot of squiggly lines as we didn't add types yet. Let's do that next.
Typing the custom hook
It's easy to type the url
and error
, as they are both strings.
For the options
, we can type it as an object with string keys and string values: { [key: string]: string }
. We can also pass a default value of {}
.
const useFetch = (url: string, options: { [key: string]: string } = {}): { data: unknown; isLoading: boolean; error: string } => {
const [data, setData] = useState<unknown>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>();
...
return { data, isLoading, error };
};
But what about data
? We don't know ahead of time what structure the server response will have. We need a way to allow the users to pass the type of the response - which we can do using generics!
Since we are using an arrow function to define the hook, to pass the generic type we can append it to the beginning of the function:
import { useEffect, useState } from "react";
const useFetch = <T>(
url: string,
options: { [key: string]: string } = {}
): { data: T | undefined; isLoading: boolean; error?: string } => {
const [data, setData] = useState<T>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
async function loadData() {
try {
setIsLoading(true);
const response = await fetch(url, options);
const data = await response.json();
setData(data);
setIsLoading(false);
} catch (e) {
setError((e as { message: string }).message);
setIsLoading(false);
}
}
loadData();
}, [url, options]);
return { data, isLoading, error };
};
export { useFetch };
Let's now run the tests and see where we stand:
npm run test
The tests for the PokemonList
component now pass, but we seem to have some failures on the useFetch
hook test:

Looks like our code fails when the sever returns 401 or 500 error codes? Why is that?
Handling HTTP error codes
Our hook uses fetch
for retrieving the data and fetch
does not throw when the server response has an error code! It simply sets the ok
property of the response to false
.
Thus, we need to manually check for it and return an error if response is not ok:
Here is the updated loadData
function:
async function loadData() {
try {
setIsLoading(true);
const response = await fetch(url, options);
const data = await response.json();
if (response.ok) {
setData(data);
} else {
setError(data.error);
}
setIsLoading(false);
} catch (e) {
setError((e as { message: string }).message);
setIsLoading(false);
}
}
And with this change, our tests now pass! 💪

Here is the final completed hook:
import { useEffect, useState } from "react";
const useFetch = <T>(
url: string,
options: { [key: string]: string } = {}
): { data: T | undefined; isLoading: boolean; error?: string } => {
const [data, setData] = useState<T>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
async function loadData() {
try {
setIsLoading(true);
const response = await fetch(url, options);
const data = await response.json();
if (response.ok) {
setData(data);
} else {
setError(data.error);
}
setIsLoading(false);
} catch (e) {
setError((e as { message: string }).message);
setIsLoading(false);
}
}
loadData();
}, [url, options]);
return { data, isLoading, error };
};
export { useFetch };
You can just paste this into the starter-repo code and your will see all tests turn green ✅.
Member discussion