Axios Wrappers in React Typescript
#apis #axios #react #typescript
7 min read
So I recently had to make some changes to a React code base that was fairly unfamiliar to me. My task was to make a PUT API request to an external endpoint, in order to update a user profile upon a certain state being toggled on.
It seemed like an easy enough task as I knew I could just tap into the installed axios
package to make API calls... until I opened up the code repo and saw abstraction upon abstraction wrapping axios
. After chasing the flow of data through multiple files, I started understanding the logic behind the wrappers, and wanted to make a note of it to solidify my understanding.
Step 1
To start, we create an ApiClient
class. This class contains a private property client
which is of type AxiosInstance
. It also has a protected method createAxiosClient
that takes in apiConfiguration
parameters to create an axios client (e.g. in this case, an access token for the API). This client is set up upon initialisation of the class. From here, we created some API methods for any instance of the class to call (get
, post
, put
and patch
), which in turn maps to axios
requests.
One of the main benefits of creating this wrapper if that we create a sort of interface between axios
and the rest of our app. This makes it easy for instance, to swap axios
out for another package, should we choose to do so in the future, without it breaking our app.
It also allows us to keep our code DRY if we have to work with multiple different APIs that each require their own axios
instances.
// src/core/api/ApiClient.ts
import appConfig from 'appConfig';
import Axios, { AxiosInstance } from 'axios';
import { RequestConfig } from 'core/api/types';
import { ApiConfiguration } from './ApiConfiguration';
import { handleServiceError } from './ApiServiceErrors';
export interface IApiClient {
post<TRequest, TResponse>(
path: string,
object: TRequest,
config?: RequestConfig
): Promise<TResponse>;
patch<TRequest, TResponse>(
path: string,
object: TRequest
): Promise<TResponse>;
put<TRequest, TResponse>(path: string, object: TRequest): Promise<TResponse>;
get<TResponse>(path: string): Promise<TResponse>;
}
export default class ApiClient implements IApiClient {
private client: AxiosInstance;
protected createAxiosClient(
apiConfiguration: ApiConfiguration
): AxiosInstance {
return Axios.create({
baseURL: appConfig.authApiBase,
responseType: 'json' as const,
headers: {
'Content-Type': 'application/json',
...(apiConfiguration.accessToken && {
Authorization: `Token ${apiConfiguration.accessToken}`,
}),
},
timeout: 10 * 1000,
});
}
constructor(apiConfiguration: ApiConfiguration) {
this.client = this.createAxiosClient(apiConfiguration);
}
async post<TRequest, TResponse>(
path: string,
payload: TRequest,
config?: RequestConfig
): Promise<TResponse> {
try {
const response = config
? await this.client.post<TResponse>(path, payload, config)
: await this.client.post<TResponse>(path, payload);
return response.data;
} catch (error) {
handleServiceError(error);
}
return {} as TResponse;
}
async patch<TRequest, TResponse>(
path: string,
payload: TRequest
): Promise<TResponse> {
try {
const response = await this.client.patch<TResponse>(path, payload);
return response.data;
} catch (error) {
handleServiceError(error);
}
return {} as TResponse;
}
async put<TRequest, TResponse>(
path: string,
payload: TRequest
): Promise<TResponse> {
try {
const response = await this.client.put<TResponse>(path, payload);
return response.data;
} catch (error) {
handleServiceError(error);
}
return {} as TResponse;
}
async get<TResponse>(path: string): Promise<TResponse> {
try {
const response = await this.client.get<TResponse>(path);
return response.data;
} catch (error) {
handleServiceError(error);
}
return {} as TResponse;
}
}
Step 2
First wrapper done, we now set up the specific service we want. I've called it ProfileService
as an example. The intermediate step that links the ApiClient
we set up in Step 1 and the ProfileService
is the concept of ProfileApiClient
.
ProfileApiClient
is a class with properties apiBase
(the base of the API endpoint we intend to query, which I keep in appConfig
since it's tied to the environment I'm working in) and profileApiClient
(a parameter we initialise this class with). With this ProfileApiClient
in particular, we ultimately need it to do 2 things: (i) to find a "waitlister" by email from an index of waitlisters, and (ii) to update a particular waitlister's status. (Imagine we're dealing with an API that manages people who have signed up to our wait list.) These are thus set up as instance methods findWaitlister(email)
and updateStatus(profileId, newStatus)
.
From here, we then define the ProfileService
class which is initialised with an instance of the ProfileApiClient
. You'll see that we have the same methods of findWaitlister(email)
and updateStatus(profileId, newStatus)
. This gives us the flexibility to say make any transformations to the data before we execute the methods, if necessary (but was not needed in this case).
// src/core/profile/ProfileService.ts
import appConfig from '../../appConfig';
import { IApiClient } from '../api/ApiClient';
import { FindWaitlisterResponse, ProfileId, ProfileStatus } from './TypesFile';
export interface IProfileApiClient {
findWaitlister(email: string): Promise<ProfileId | undefined>;
updateStatus(ProfileId: number, newStatus: ProfileStatus): Promise<boolean>;
}
export class ProfileApiClient implements IProfileApiClient {
apiBase: string;
profileApiClient: IApiClient;
constructor(profileApiClient: IApiClient) {
this.apiBase = appConfig.profileApiBase;
this.profileApiClient = profileApiClient;
}
async findWaitlister(email: string): Promise<ProfileId | undefined> {
try {
const response = await this.profileApiClient.get<FindWaitlisterResponse>(
`${this.apiBase}/waitlisters?email=${email}`
);
return response.length > 0 ? response[0].id : undefined;
} catch (exception) {
console.error(exception);
}
}
async updateStatus(
profileId: number,
newStatus: ProfileStatus
): Promise<boolean> {
try {
await this.profileApiClient.put(
`${this.apiBase}/waitlisters/${profileId}`,
{ tester: { status: newStatus } }
);
return true;
} catch (exception) {
console.error(exception);
return false;
}
}
}
export default class ProfileService {
profileApiClient: IProfileApiClient;
constructor(profileApiClient: IProfileApiClient) {
this.profileApiClient = profileApiClient;
}
async findWaitlister(email: string): Promise<ProfileId | undefined> {
return this.profileApiClient.findWaitlister(email);
}
async updateStatus(
profileId: ProfileId,
newStatus: ProfileStatus
): Promise<boolean> {
return this.profileApiClient.updateStatus(profileId, newStatus);
}
}
Step 3
As there are a bunch of other services we need to spin up for other parts of the code base, we collate everything in a services.tsx
file so that it's easy to keep track of them all. For security purposes, I store the profileApiKey
and profileApiPassword
in the appConfig
file as environment variables.
// src/services.tsx
import ApiClient from './core/api/ApiClient';
import { ApiConfiguration } from 'core/api/ApiConfiguration';
import appConfig from './appConfig';
import ProfileService, {
ProfileApiClient,
} from './core/profile/ProfileService';
// other services you might want to set up...
const profileApiConfig = new ApiConfiguration();
profileApiConfig.accessToken = appConfig.accessToken;
const profileApiClient = new ProfileApiClient(new ApiClient(profileApiConfig));
export const profileService = new ProfileService(profileApiClient);
Step 4
Finally, bringing it all together in the component I want to make the API call in, within a useEffect
hook, I make the API call from within an IIFE which I've called checkRequirements
here. To make it more readable, you can first define the checkRequirements
function, then call it in a separate line. What you can't do it to call make the API request directly, as this returns a promise which is not something useEffect
expects.
// src/example/ExampleComponent.tsx
import React from 'react';
// import whatever else you need including allInOrder function used below
import { profileService } from 'services';
export type ExampleComponentProps = {
// ...
};
// define allInOrder function
const updateProfileStatus = async (email: string) => {
const existingWaitlisterId = await profileService.findWaitlister(email);
if (existingWaitlisterId) {
const successfullyUpdated = await profileService.updateStatus(
existingWaitlisterId,
'active'
);
return successfullyUpdated
? null
: console.error(
'Could not update user profile, with profile ID: ',
existingWaitlisterId
);
}
};
const ExampleComponent: React.FC<ExampleComponentProps> = (
{
// ...
}
) => {
// ...
useEffect(() => {
(async function checkRequirements() {
if (await allInOrder(documents)) {
updateProfileStatus(documents.email);
// and do this...
} else {
// Do something else
}
})();
});
// ...
return {
/* ... */
};
};
Update
I received a question recently from a reader on what ApiConfiguration
and RequestConfig
are. Here's what I configured them as:
export type HttpHeaders = {
[key: string]: string;
};
export type RequestConfig = {
headers: HttpHeaders;
};
export class ApiConfiguration {
accessToken?: string;
}