Avatar

👋🏻, I'm Julia.

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

© 2016-2024 Julia Tan · Powered by Next JS.