🪝 Building custom "useTypedFetch" hook in React with TypeScript
vova ushenko
Posted on September 29, 2021
Asynchronous interactions are the bread-and-butter of modern JavaScript programming. Let’s see how we can abstract away all the heavy lifting and boilerplate of data fetching in a custom useTypedFetch hook 🪝.
✨ This article is particularly aimed at beginners and those who want to familiarize themselves with async TypeScript a little better and maybe start using it with React.
Motivation
Since it might be tricky to work with dynamic types of data in TypeScript (and almost all api calls, per se, are purely dynamic), we want to build a flexible tool that will adapt to any kind of an api response and will do this dynamically.
Additionally, it would be great to make this hook "http-client-agnostic". Put simply, this hook should get a standardized input (url, method, headers etc) and should seamlessly work with different types of http-clients (fetch, axios etc). This will allow us to easily migrate from one http-client solution to another(if need be) without rewriting hundreds of api calls and thousands of line of code.
❗Note : This is for learning and academic purposes only. In production, I would advise to rely on established solutions and libraries.
Foundation
Let’s start from the brain of our fetching logic - fetch client or request function. It should be able to make all types of calls (‘GET’, ‘PUT’, ‘POST’, ‘DELETE’, etc). It’s single responsibility should be just making calls using native fetch or axios (or some other library). By delegating all calls in our app to this one fetch client, we can make our app far more robust, since calls will not be diluted all over the project(and when we will decide to migrate to some other fetching library or solution this will be super easy).
Let’s start from the interface of our fetch-client function.
If we use native fetch, we can automatically make a “GET” request by only specifying “url”. Other params are optional. So let’s mimic standard fetch interface
interface RequestConfig {
url: string;
method?: string;
headers?: Record<string, string>; //💡 “?” - specifies optional field
data?: Record<string, string | number>;
}
💡 Notice, headers and data use nifty Record utility which actually constructs an object type, whose property keys are of first type specified in generic( in our example - string) and values are specified by second type in generic.
For simplicity’s sake, we will not include a list of all possible params.
❗ In “headers” and “data” we will be specifying config objects to be added to headers and body
For instance,
headers: {
'Content-Type': 'application/json',
}
Global Fetch Function
Now we are ready to build our global request function, it takes an object with interface RequestConfig and returns a promise as all async functions do:
export const makeFetchRequest = ({
url = '/',
method = 'get',
data,
headers,
}: RequestConfig): Promise<Response> => {
return fetch(url, { method, headers, body: JSON.stringify(data) });
};
💡 Notice that the input params have RequestConfig type which we will soon use for another variant using “axios”. This current implementation is based on native “fetch”. Additionally we specified in the generic of returned promise - <Response>
which is a native fetch response (IDE will provide useful hints).
Here how it looks under the hood...
/** This Fetch API interface represents the response to a request. */
interface Response extends Body {
readonly headers: Headers;
readonly ok: boolean;
readonly redirected: boolean;
readonly status: number;
readonly statusText: string;
readonly type: ResponseType;
readonly url: string;
clone(): Response;
}
We’re half way through! 🍾
As you might have noticed, all our accomplishments have nothing to do with React (which is great). Because we could abstract away our fetch logic even from the framework and later re-use or consume it in other frameworks ( if we use micro-frontend architecture with many frameworks).
Let’s now get back to the React land and think about the basic state of our useTypedFetch hook.
In its simplest implementation, it should receive a url and request options and hopefully return some data, error and loading indicators after making some api call.
Since we already created an interface for our makeFetchRequest function, let’s re-use it!
Here is our useFetch Function initial signature
export const useTypedFetch = ({ url }: RequestConfig) => {
// ⭐ api response data will be stored here!
const [fetchedData, setFetchedData] = useState<any>(null);
// ⭐ loading flag
const [isLoading, setIsLoading] = useState<boolean>(false);
// ⭐ errors piece of state
const [error, setError] = useState<any>(null);
/*
🌠 Some magic happens here 🌠
*/
return { fetchedData, isLoading, error };
};
💡 The biggest problem with async operations is that we don’t know what the type of an api response we will get beforehand. So we cannot hard-code it here in useTypedFetch.
❗ We also want to make this hook adaptable to any kind of API (and not resort to any type or cumbersome type narrowing with unknown)
Sounds really complicated to create a function that will use a type that we don’t know beforehand, but the solution is really simple - generics 💡.
Let’s start from the type of what we will actually get from useTypedFetch
We will call this type very simply - UseTypedFetchReturn
type UseTypedFetchReturn<T> = {
data: T | null;
isLoading: boolean;
error: string | null;
};
Generic <T>
will be added at useTypedFetch
function’s call-time and we will be able to specify it and receive all the type safety and hinting support of TypeScript. Which makes me happy! 🤗
Let’s implement this in the hook
// ❗Notice we put `<T>` before function's param list
export const useTypedFetch = <T>({
url,
headers,
method,
data,
}: RequestConfig): UseFetcherReturn<T> => {
//⭐ We also use T in generic of fetchedData, since it essentially what we will get from an API
const [fetchedData, setFetchedData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
/*
🌠 Some magic will soon happen here, be patient 🌠
*/
return { data: fetchedData, isLoading, error };
};
💡 Generic <T>
that is added before the function will specify our API response data type (which we can get from our backend libs or specify on our own). We can use any name instead of <T>
, for instance <SomeCoolGenericType>
.
💡 Notice that at this stage we firstly specify this generic type before the hook and then “consume” it in the hook’s return type UseFetcherReturn<T>
and in local data state useState<T | null>(null)
. Essentially, we construct our whole “type-flow” based on this specified type. Sweet! 🤗
✨ Voila, now we can specify any type before each fetch call in useTypedFetch and get all the Typescript hints and benefits dynamically.✨
Now we are ready to actually fetch
Let’s add useEffect and update our hook’s pseudocode
export const useTypedFetch = <T,>({
url,
headers,
method,
data,
}: RequestConfig): UseTypedFetchReturn<T> => {
const [fetchedData, setFetchedData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
return { data: fetchedData, isLoading, error };
};
useEffect(() => {
try {
setLoading(true);
const data = makeSomeRequest();
setFetchedData(data);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
doSomethingWithError();
setError(error);
}
}, []);
We probably will makeSomeRequest(), and if everything will be OK, we will store api response data in the local state and return it ready for further consumption from the hook. Otherwise we will doSomethingWithError() (be it a second call, abort, log error or simply store error in the local state and return it).
💡 However, I really want to abstract the logic of makeSomeRequest() away from the hook (like we did with makeFetchRequest ) and create a helper function. Let’s name it “fetcher”.
In this helper we will use our main fetch-client “makeFetchRequest”, and it will look like:
export const fetcher = async <T,>({
url,
method,
headers,
data,
}: RequestConfig): Promise<T> => {
// ⭐ make api call
const apiResponse = await makeFetchRequest({ url, method, headers, data });
// ⭐ call json() to transform a Response stream into usable JSON
const apiData: T = await apiResponse.json();
return apiData;
};
💡 Notice we again re-use the RequestConfig interface and use generic to specify what will be returned. Fetcher is an async function, so it obviously returns a promise.
Let’s get back to the hook and integrate this fetcher helper.
export const useTypedFetch = <T,>({
url,
headers,
method,
data,
}: RequestConfig): UseTypedFetchReturn<T> => {
const [fetchedData, setFetchedData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
//⭐ we use IIFE to automatically invoke fetcher
(async () => {
try {
setIsLoading(true);
const res = await fetcher<T>({ url });
setFetchedData(res);
setIsLoading(false);
} catch (err) {
setIsLoading(false);
//⭐ here we can narrow the type of error (if for instance we have our own custom error class or do something else)
if (err instanceof Error) {
setError(err.message);
}
}
})();
}, [url]);
return { data: fetchedData, isLoading, error };
};
✨ Now we can use this hook with any kind of an API and get all the type safety and convenient hinting along the way. ✨
Using the hook
Let’s get back to React land and make several api calls. We will test our hook with GET and POST requests. We will use https://jsonplaceholder.typicode.com/ as our mock backend API.
You could play around with the working example on CodePen
In a nutshell, we specify types of Todo and Post which will be returned from API stored in arrays.
1️⃣ We make GET calls to API/todos and API/posts to get data.
2️⃣ We also make a POST call to store a new post.
3️⃣ We also use this data to render basic JSX.
/**
* INTERFACES OF https://jsonplaceholder.typicode.com/
*/
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
const API = 'https://jsonplaceholder.typicode.com';
function App() {
//⭐ Example of making GET call to get array of Todos
const {
data: todos,
error,
isLoading,
} = useTypedFetch<Todo[]>({
url: `${API}/todos`,
});
//⭐ Example of making GET call to get array of Posts
const { data: posts } = useTypedFetch<Post[]>({
url: `${API}/posts`,
});
//⭐ Example of making POST request to create a new post, no pun intended
const { data: postData } = useTypedFetch<Post>({
url: `${API}/posts`,
method: 'POST',
data: { title: 'foo', body: 'bar', userId: 1 },
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
return (
<Container>
{todos?.slice(0, 3).map((todo) => (
<article>
<h2>{todo.title}</h2>
<p>{todo.completed}</p>
</article>
))}
{posts?.slice(0, 3).map((post) => (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
))}
<article>
Here is our newly POST-ed post
{JSON.stringify(postData, null, 2)}
</article>
{error && <h1>{error}</h1>}
</Container>
);
}
I think everything is pretty basic and self-explanatory. Please notice that in the generic of useTypedFetch we specify what kind of data shape we are expecting and right away in the code we will get useful code completion and hinting. Which makes me happy 😍
Using with axios
Finally! Let’s enhance our hook with variability. So far, we’ve constructed it on top of fetch. Let’s add axios!
Our initial step was to specify the interface of request configuration and axios already did all the heavy-lifting and provides AxiosRequstConfig interface.
Let’s build our global request function.
export const makeAxiosRequest = <T,>({
url = '/',
method = 'get',
data,
headers,
}: AxiosRequestConfig): AxiosPromise<T> => {
return axios({ url, method, data, headers });
};
💡 As you might have noticed it looks like our initial makeFetchRequest function. Of course it has built-in axios interfaces and it uses axios as the http-client. But it takes exactly the same params as input, which is great!
Let’s add a axios fetcher function, like previously with “fetcher”.
const axiosFetcher = async <T,>({
url,
method,
headers,
data,
}: AxiosRequestConfig): Promise<T> => {
const { data: apiData } = await makeAxiosRequest<T>({
url,
method,
headers,
data,
});
return apiData;
};
💡 If you will compare it with our initial fetcher, you will notice that it takes exactly the same input and produces exactly the same output! We now actually have two options in our useTypedFetch, we can use either fetch or axios ✨!
Let’s see this in the hook
export const useTypedFetch = <T,>({
url,
headers,
method,
data,
}: RequestConfig): UseTypedFetchReturn<T> => {
const [fetchedData, setFetchedData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setIsLoading(true);
// ⭐⭐⭐ Both res1 and res2 return
//the same response of the same type!
//Now we can have a flexibility to use either
// fetch or axios ⭐⭐⭐
const res1 = await axiosFetcher<T>({ url, headers, method, data });
const res2 = await fetcher<T>({ url, headers, method, data });
setFetchedData(res1);
setIsLoading(false);
} catch (err) {
setIsLoading(false);
if (err instanceof Error) {
setError(err.message);
}
}
})();
}, []);
return { data: fetchedData, isLoading, error };
};
🍾 That’s it guys! Hope you enjoyed this simple overview and learned something new!
Cheers! 🙌
You can find the code used in this blogpost at the following sandbox URL: https://codepen.io/vovaushenko/pen/bGRQbRE
Posted on September 29, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.