DEV Community

Cover image for 🪝 Building custom "useTypedFetch" hook in React with TypeScript
vova ushenko
vova ushenko

Posted on

🪝 Building custom "useTypedFetch" hook in React with TypeScript

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>;    
}
Enter fullscreen mode Exit fullscreen mode

💡 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',
   }
Enter fullscreen mode Exit fullscreen mode

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) });
};

Enter fullscreen mode Exit fullscreen mode

💡 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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
};

Enter fullscreen mode Exit fullscreen mode

💡 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;
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

💡 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);
   }
 }, []);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

💡 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 };
};
Enter fullscreen mode Exit fullscreen mode

✨ 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>
 );
}
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

💡 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;
};
Enter fullscreen mode Exit fullscreen mode

💡 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 };
};
Enter fullscreen mode Exit fullscreen mode

🍾 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

Top comments (3)

Collapse
 
hedwardd profile image
Heath Daniel

Wow, I'm shocked there aren't any comments on this. I found this super interesting and helpful. I've always wanted a typed fetch but never got around to implementing one.

I'm curious, you mention that this is for academic purposes and you would recommend using "established solutions and libraries" in production. Can you give me an example of one of those?

And great article btw. Thanks!

Collapse
 
vovacodesca profile image
vova ushenko • Edited

Hey Daniel, thanks!

I think solutions with built-in caching are more preferable. Caching, arguably, is one of the most complicated parts in building software, so it’s pretty cool to delegate this responsibility to some library (managing and maintaining your own solution can be a nightmare).

I have no particular preference regarding libraries, but in the React world - React Query is gaining more and more popularity. Honestly, I have never used it in production, so I have no real opinion yet.

With Next.js, I could recommend useSWR which in tandem with static generation will result in a modern dynamic app which is kinda pre-generated on the server (best of both worlds).

Anyways, there is nothing wrong with fetch or axios. In my apps, I often use axios in tandem with redux and thunks (slightly different architecture than we discussed here).

Cheers!

Collapse
 
hedwardd profile image
Heath Daniel

That makes sense. I'll definitely be looking into React Query (and keeping an eye out for the others) now. Thanks!