Mastering API Calls in React: Implementing an HTTP Client
7 Dec 2023
In the following article, Grisha Vasilyan, one of our front-end professionals, discusses the importance of structured applications and their benefits in terms of ease of modification and debugging.
In this article, I will introduce my approach to working with API on React applications.
Most frontend applications communicating with the server use API calls, and those applications include a lot of API calls. When we do this the non-centralized way, we end up with code duplications and struggle with modifying or debugging.
What if we have some sort of structured HTTP client, which will provide the functionality to have common configurations, easy and fast modifications, and debugging?
From my perspective, it’s important to work with an application where every part is structured. Sometimes, I spend a lot of time structuring an application and only after that start working on the main features. If you agree with me, let's understand what we will cover in this article.
The article is divided into two main sections:
1. The implementation details of the HTTP Client
2. Step-by-step guide to the integration of the HTTP Client into the React application
The Implementation Details of the HTTP Client
The HTTP Client package is implemented at the top of the Axios, which provides a lot of robust features. You can find this package on the npm as @vasg/httpclient and the source code on GitHub.
There are two main parts: core client and URL parser. Let’s look into this in more detail now.
The core client includes all the needed functionalities to work with. It’s similar to the Axios but there is a slight difference which I'll explain now.
The first important part of this is the constructor of the client, which uses a configuration object to create a new client instance. So, we can instantiate new client instances by passing configurations which is the type of CreateAxiosDefaults.
constructor(configs: CreateAxiosDefaults) {
this.client = this.createInstance(configs);
}
private createInstance(configs: CreateAxiosDefaults) {
return Axios.create(configs);
}
I made this.client class property private. If you need to do specific actions on the client instance then you can get that from the GetClientInstance method.
The next important part is HTTP methods. The core client includes all types of HTTP methods like GET, POST, PUT, DELETE, etc.
public post<TR = any>(requestConfig: TRequestArguments): Promise<AxiosResponse<TR>> {
const { route, data, replacements, config } = requestConfig;
config = this.bindAuthFlag(route, config);
return this.client.post(
this.genRequestUrl(route.url, replacements),
data,
config,
);
}
The example above displays the POST method, where I made some modifications. Every method should get an argument as a type of TRequestArguments, which contains the following properties:
type TRequestArguments = {
route: TApiEndpoint;
replacements?: TURLReplacement;
config?: AxiosRequestConfig;
data?: any;
};
As you know, arguments change depending on the type of HTTP method. For instance, if you use the GET method, you don’t pass data property, but in the case of POST, you can do the same logic as here.
Let’s now understand the meanings of these properties:
Route - this property contains information about the requested URL, HTTP method, and a Boolean flag to specify whether this request needs or doesn’t need an access token. You can also add any placeholder you want on the URL property.
type TApiEndpoint = {
url: string;
method: TRequestMethod;
isRequiredAuth: boolean;
};
Replacements - an object with a key-value pair which needed to parse the request URL. This you can use in case you have query string or dynamic URL paths as said above when speaking about the route property. So, the meaning of this replacement object is to replace placeholders with the provided values.
Config - configuration for this specific request, so you can do the global configuration for all requests and local for specific ones.
Data — this is the request body.
And the last part is the URL Parser, which is designed to provide the functionality of working with URLs. This part will provide the functionality to modify URLs and use the replacement objects which we spoke about before.
Let’s look at an example to clearly understand the workflow. For instance, we need to get specific user posts with count limitations.
First, let’s create an HTTP client instance:
const httpClient = new HttpClient({
baseURL: "https://api.domain.com/"
});
After creating the instance we can use it to make a request:
async function getUserPosts() {
const request = await httpClient.get({
route: {
url: "api/:userId/posts?:queryString",
method: "GET",
isRequiredAuth: true
},
replacements: {
userId: 123,
queryString: { limit: 20 },
}
});
}
NOTE: The placeholder on the URL and the replacements object keys should be the same in order to work as expected. As you already guessed, for a query string you can define a placeholder as a queryString and provide the replacement as an object with a key-value pair.
The code above seems very long, doesn’t it? But don’t worry, we have a great option to minimize it. In the next section, we will create a separate file where we can define all of our routes and use those.
The code below shows the binding authorization flag on the request configuration which we can get from the route object.
private bindAuthFlag(
route: TApiEndpoint,
config?: AxiosRequestConfig,
): AxiosRequestConfig {
config = config || ({} as AxiosRequestConfig);
config.requiresAuth = route.isRequiredAuth;
return config;
}
So, after all the modifications are done, call the original HTTP method from the Axios instance which we saved on this.client property.
this.client.post(
this.genRequestUrl(route.url, replacements),
data,
config,
);
Mostly, we cover all the important parts of HTTP client implementation. There are a few small things that I will speak about in the next section.
Below is the source code you can find on GitHub and integrate it with your application from npm.
# npm
npm install @vasg/http-client
#yarn
yarn add @vasg/http-client
Also, take into account that every application has different business logic, so if needed, you can find the source code from GitHub, clone it, and customize it to align with your requirements. If you happen to find any type of issue, please, file an issue on GitHub. (Issues)
git clone https://github.com/grish97/http-client.git
A Step-by-Step Guide to the Integration of an HTTP Client into React Application
In this section, we will set up a fresh React application and will integrate an HTTP Client package. Let’s jump right in.
To set up the React application with Typescript, I’ll use Vite.
# npm 7+, extra double-dash is needed:
npm create vite@latest react-http-client -- --template react-ts
# yarn
yarn create vite react-http-client --template react-ts
After the React application is installed, we need to install dependencies and the HTTP Client package.
# npm
npm install
npm install @vasg/http-client
#yarn
yarn install
yarn add @vasg/http-client
Now we can configure our client to work with. Let’s create a folder for client-related files on src/utils/http. In this folder we will have the following file:
client.ts - here we will define our clients and export those to use all parts of the project:
// src/utils/http/client.ts
import { HttpClient } from "@vasg/http-client";
export const privateClient = new HttpClient({
baseURL: import.meta.env.VITE_API_DOMAIN,
requiresAuth: true,
});
export const publicClient = new HttpClient({
baseURL: import.meta.env.VITE_API_DOMAIN,
});
In the code above I created two clients for private and public requests. In privateClient, I added a common config that will tell the client that all requests made by this client should have an access token. We will discuss this part later though.
The apiEndpoints.ts file includes all endpoint definitions:
// src/utils/http/apiEndpoints.ts
import { TApiEndpoints } from "@vasg/http-client";
export const apiEndpoints: TApiEndpoints = {
API_LOGIN: {
url: "login",
method: "POST",
isRequiredAuth: false,
},
POSTS_BY_USER_ID: {
url: "user/:userId?:queryString",
method: "GET",
isRequiredAuth: true,
},
POST_UPDATE: {
url: "user/:userId/posts/:postId",
method: "POST",
isRequiredAuth: true,
},
};
In this case, we can very easily manage all our endpoints; modify them from one place, and use those with shorthand syntax.
The index.ts file is the entry point of our client files:
// src/utils/http/index.ts
export { privateClient, publicClient } from "./clients";
export { apiEndpoints } from "./apiEndpoints";
The HttpClientProvider.tsx file is a React class component intended to set up interceptors for requests. I will describe an important part of this component with short snippets, but you can find the full file on GitHub (HttpClientProvider.tsx).
In our case, I set up interceptor only for private requests with the following functionalities:
- Automatically add an access token to the request depending on the requiresAuth flag. We can specify this flag commonly which we did when creating a new instance or by adding it to the endpoint’s object using isRequiredAuth.
privateClient.useRequestInterceptor(
async (config) => {
const userToken = localStorage.getItem("access_token");
if (config?.headers && config.requiresAuth && userToken) {
// add access token on request config
}
return config;
},
(error) => Promise.reject(error),
);
- Intercept failed response and catch which is related to authorization and run some functionality, like refresh token.
privateClient.useResponseInterceptor(
async (response) => response,
async (error) => {
const originalRequest = error.config;
if (error?.response?.status === 401 && !originalRequest.retry) {
// refresh token functionality here
}
return Promise.reject(error);
},
);
In the source code, you can also find a functionality that will repeat all requests that failed depending on the authorization issue after refreshing the token. And now we can wrap our application with this HTTP client provider.
NOTE: If you use redux state management library, you need to add provider under the store provider. On this project, I used the redux refreshToken method which is an async action:
// main.tsx
root.render(
<StoreProvider>
<HttpClientProvider>
<App />
</HttpClientProvider>
</StoreProvider>,
);
Looks like this is all about the configuration of our clients. Now we can move on and create our mock API which we will use to test our client work. If you have a ready server you can use that.
For the mock API, I will use the Mock Service Worker library:
#npm
npm install msw
#yarn
yarn add msw
This library is easy to set up and use. On the browser, we can use a service worker to set up our mock API.
First, we need to run MSW cli to setup the service worker file on our public folder:
npx msw init ./public
The command will add a mockServiceWorker.js file to the public folder, if we run the application and navigate to the /mockServiceWorker.js path, we should see the service worker source code.
Next, we need to configure our mock API functionality by creating a folder named mock and adding the following files:
// mock/browser/worker.ts
import { setupWorker } from "msw/browser";
import { handlers } from "../handlers";
export const worker = setupWorker(...handlers);
In the file above, we can see the handler file, where we will add our routes, like this:
// mock/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
// Login user
http.post(
`${import.meta.env.VITE_API_DOMAIN}/${apiEndpoints.API_LOGIN.url}`,
() => {
return HttpResponse.json(userData, {
status: 200,
});
},
),
// other routes...
];
On the handler file, you can add all your routes and respond to the request with data. Full examples of routes can be found in the handlers.ts file on the GitHub repository.
Now the only thing left is to add the enable functionality which will register and activate the service worker:
// mock/browser/enable.mock.ts
export async function enableMocking() {
if (process.env.NODE_ENV !== 'development') {
return;
}
const { worker } = await import('./worker');
return worker.start()
}
Since registering the service worker is an asynchronous operation, it’s a good idea to defer the rendering of our application until the registration Promise is resolved. So we should enable our service worker on the entry file:
// main.tsx
import ReactDOM from "react-dom/client";
import { StoreProvider } from "@store";
import { HttpClientProvider } from "@http";
import { enableMocking } from "@mock/browser/enable.mock";
import App from "./App";
function bootstrap() {
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
root.render(
<StoreProvider>
<HttpClientProvider>
<App />
</HttpClientProvider>
</StoreProvider>,
);
}
enableMocking().then(() => bootstrap());
Now we are done with all the configurations and can look at small examples of how we can use our client.
import { useCallback, useEffect } from "react";
import { apiEndpoints, privateClient } from "@http";
export default function HTTPClientExample() {
useEffect(() => {
getUserPosts();
}, []);
const getUserPosts = useCallback(async (userId: number, limit: number) => {
const response = await privateClient.post({
route: apiEndpoints.POSTS_BY_USER_ID,
replacements: {
userId: userId,
queryString: {
limit: limit,
}
},
});
return response;
}, []);
const updatePost = useCallback(async (userId: number, postId: number, data: any) => {
const response = await privateClient.post({
route: apiEndpoints.POST_UPDATE,
replacements: {
userId: userId,
postId: postId,
},
data: postId,
});
return response;
}, []);
return (
<main>
{/** Render user posts here */}
</main>
);
}
This is just a demonstration to help you understand how we can use the HTTP client. Now you can clone this application from GitHub and play with it.
# clone from GitHub
git clone https://github.com/grish97/http-client.git
# change directory to http-client
cd http-client
# install dependencies
yarn install
# run application
yarn dev
After running the application, you will find a playground for demonstrating refresh token functionality using the HTTP client and interceptors.
Summary
In conclusion, we’ve explored the significance of structured applications and their benefits in terms of ease of modification and debugging. The discussion focused on the HTTP client package, showcasing its comprehensive functionality for managing HTTP requests and providing robust URL customization.
The insights shared here underline the importance of structured approaches in application development and highlight the practical advantages of utilizing tools like the HTTP client package for efficient and reliable HTTP request handling. Should there be any further queries, I am more than happy to address them.
Hope you learned something new. Thank you for reading!