From Fetch to Axios — A better way to call APIs

From Fetch to Axios — A better way to call APIs

Guide on how to rewrite fetch implementations to use axios with a use case example of calling SuperTokens API

·

12 min read

Introduction to fetch

Fetch is a Web API for you to make API requests and fetch resources in an easy way. It is also how most new web developers learn to call APIs. According to MDN Docs,

  • it returns a promise that will not reject on HTTP error status such as 400 or 500.
  • by default, it also does not send cross-origin cookies.

Here’s an example of using fetch in JavaScript to make a GET request with error handling.

const getRandomTodo = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
        const {data, errors} = await response.json()    
        if (response.ok) {
            return data
        } else {
            return Promise.reject(errors)
        }
}

The response body looks something like this

{
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
}

In order to know the actual structure of the response body as shown above, you would have to call the API or refer to its documentation. From the code alone it is not obvious what this response looks like.

This is where Typescript comes in handy by allowing you to type the responses that you expect to get from your fetch implementation. That way, if a team of developers works on a common codebase, everyone other than the developer that worked on this piece of code can also easily understand what the API returns and have intellisense autocomplete features for accessing this data’s properties.

To use fetch in Typescript, we simply create a type for the response and set the output of the function to be a Promise of that type.

type TodoResponse = {
    userId: number,
  id: number,
  title: string,
  completed: false
}

const getRandomTodo = async (): Promise<TodoResponse> => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
        const {data, errors} = await response.json()    
        if (response.ok) {
            return data
        } else {
            return Promise.reject(errors)
        }

Looks easy so far right? Now let me share with you a use case where using fetch could result in a lot of boilerplate code.

When fetch is not good enough

In a previous article, I’ve implemented a feature to sign up users with SuperTokens email password authentication on a React Native app with a fetch implementation.

This is how the function looks like.

const signUpUser = ({ email, password }: {
  email: string;
  password: string;
}): Promise<any> => {
  const myHeaders = new Headers();
  myHeaders.append('rid', 'emailpassword');
  myHeaders.append('Content-Type', 'application/json');

  const raw = JSON.stringify({
    formFields: [
      {
        id: 'email',
        value: email,
      },
      {
        id: 'password',
        value: password,
      },
    ],
  });

  const requestOptions = {
    method: 'POST',
    headers: myHeaders,
    body: raw,
  };

  return fetch('http://localhost:3001/auth/signup', requestOptions);
};

🐙 If you have not followed along that tutorial before, you can refer to the code found here, under components/SignUp.tsx

What is different about this function compared to the examples given before is that:

  • Rather than returning a promise that gives me the data I want from the API directly, I’m returning a promise that simply calls the fetch function.
    • This is because I want the frontend to be handling the response & data on the component itself.
    • As a result, I could only type the output of the function as Promise<any>
  • In order to call the SuperTokens API, I also needed to pass in the following data in the request
    • Headers: rid, Content-Type
    • Request body: a specific object structure
    • Method: POST

While this fetch implementation works to address this use case, the code can quickly become very unwieldy. While it is perfectly fine to use fetch for every function that we write to call APIs, for each of these, we would have to:

  1. create these headers manually
  2. JSON.stringify the object that should be in the body
  3. inspect the object requestOptions to see that the API is using the “POST” method
  4. type the result as Promise<any>

As we expand our codebase later on to include other functions say signInUser, logOutUser etc, you can imagine how the code will start to look even more messier.

Hence this is a good time to introduce Axios to you — a simple promise based HTTP client that helps you address each of these problems ✨ After the introduction, then we will proceed on applying the knowledge we learn onto this use case.

Introduction to axios

Axios is an open source library with many of nifty features to make the experience of calling APIs a breeze. In this article, I’ll be sharing with you 3 things that make axios cool ✨

  1. Default Request Config
  2. Instance Methods
  3. Global Defaults

To use axios, simply install it via NPM. It also comes with typescript definitions to improve your development experience.

npm i axios

Default Request Config

The default axios request config follows closely to common good practices for calling APIs and is sufficient for many use cases in the real world. There are many properties that you can configure in the axios request config, however I will just be highlighting 2 of such properties — validateStatus & baseURL since they are more interesting topics to be compared to fetch.


validateStatus

Previously, we mentioned that fetch returns a resolved promise even if the response status is 4xx or 5xx. For axios however, it is designed by default that if the response status is ≥200 and <300, the promise will be rejected. The latter felt more intuitive in terms of promise rejection handling and hence is more prevalent in implementations.

This is set in the in a validateStatus property with a predicate function for resolving the promise. If you prefer to emulate fetch’s implementation of rejecting the promise manually when responses with error status codes, you can choose to override the predicate function below in the request config object that we are passing to the instance methods. For this article, we won’t be doing this.

import {AxiosRequestConfig} from 'axios';

const requestConfig: AxiosRequestConfig = {
    validateStatus: function (status) {
        return status >= 200 && status < 300;
    }
}

💡 Additional info on API response promises

If you are curious on the design decision for why fetch does not follow the ‘norm’ to reject the promise automatically for error status, you can refer to this GitHub issue’s discussions. In one of my work projects, we were also mandated to use an enterprise common library that adds interceptors to axios such that status 400 does not reject the promise for some specific business use cases. So while it is uncommon, there are use cases for overriding this validateStatus function.


baseURL

It is often that we are using a group of APIs from a common provider, and that provider will be hosted on a specific domain. For example, if we start up a local server that serves API, all the APIs that our server will expose would be endpoints that follow a format like http://localhost:3000/signup, or http://localhost:3000/signin. The former part http://localhost:3000/ is what we refer as the baseURL.

fetch does not natively support the concept of having a baseURL, you would to create variables like the following repeatedly for various endpoints.

const BASE_PATH = "http://localhost:3000"
const SIGNIN_PATH = BASE_PATH + "/signIn"
const FUTURE_SERVICE_PATH = BASE_PATH + "/futureService"

There are also somewhat convoluted ways of integrating baseURL with the fetch API, but I won't go down that rabbit hole here.

Meanwhile, Axios knows of this API service pattern, so it provides a request config property that you can set for the baseURL directly. That way, you only need to set the new route paths, and not be concerned about how the whole endpoint URIs are constructed e.g. Did you put a slash after the baseURL? 🤔 This can be especially helpful when it comes to deploying apps where you may also have different API baseURL for different environments.

import {AxiosRequestConfig} from 'axios';

const requestConfig: AxiosRequestConfig = {
    baseURL: "http://localhost:3000"
}

const SIGNIN_PATH = "/signIn"

From this code, this may not look like a significant improvement from fetch yet since we are just creating another type of object to hold the same data right now, but once we get to configure global axios defaults later, it will make a lot of sense. For now, let’s talk about examples of actually calling the API with axios itself.


Instance methods

The axios library is designed for you to write declarative code. Want to call an API with a specific http method like GET, POST or DELETE? Simply use instance methods. The library is designed so that it is not mandatory for you to pass in a request config object for simple API calls. Notice that only POST, PUT, PATCH has an additional optional input parameter data.

axios.get(url[, config])
axios.delete(url[, config])
axios.post(url[, data[, config]])
axios.put(url[, data[, config]])
axios.patch(url[, data[, config]])

No idea what any of these parameters should be like? With Typescript, you can hover over each of these functions and see the expected parameter types. Here’s the type definition for the function axios.post(url[, data[, config]])

post<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;

If you are not familiar with Typescript Generics, you can refer to this guide by Shu Uesugi.

For a more direct comparison, in the fetch implementation to call the SuperTokens API, we have to pass a method property into the request config object.

const myHeaders = new Headers();
myHeaders.append('rid', 'emailpassword');
myHeaders.append('Content-Type', 'application/json');

const raw = JSON.stringify({
  formFields: [
    {
      id: 'email',
      value: email,
    },
    {
      id: 'password',
      value: password,
    },
  ],
});

const requestOptions = {
  method: 'POST', // here
  headers: myHeaders,
  body: raw,
};

fetch('http://localhost:3001/auth/signup', requestOptions);

But with the axios implementation, the instance method tells us immediately what method we are using to call the API.

import axios, {AxiosRequestConfig} from 'axios';

const requestConfig: AxiosRequestConfig = {
    baseURL: 'http://localhost:3000',
    headers: {
        'rid': 'emailpassword',
        'Content-Type': 'application/json'
    }
}

const SIGNUP_PATH = '/signup'

axios.post(SIGNUP_PATH, {
  formFields: [
    {
      id: 'email',
      value: email,
    },
    {
      id: 'password',
      value: password,
    },
  ],
}, requestConfig);

Here, the request body is not cluttered together with the request config. We also didn’t have perform the JSON.stringify operation because axios will do the transformation for us automatically. ✨

Next, we can also say good bye entirely to that request config object with global axios defaults.

Global defaults

If we are calling APIs with specific headers to a specific base url all the time , won’t it be great if there’s a way to set these defaults? Well, doing that with Axios is very simple!

In our use case, we always had to call the API at the same baseUrl and similar headers. Hence, we could set the defaults as such at the start of the app.

axios.defaults.headers.rid = 'emailpassword';
axios.defaults.headers.post['Content-Type'] = 'application/json';
axios.defaults.baseURL = 'https://api.example.com';

In the case where you have multiple adaptors for calling different APIs with different base URLs, you might not want global defaults. For those situations, you could create custom instances, each with their own baseUrl. For that, you can refer to the custom instance defaults section.

⚠️ If you're following along with the mobile app code, both fetch and axios works to call SuperTokens API. However, with axios, we will need to manually add the interceptors. This is how you can do it, as referenced from the SuperTokens recipe instructions to add API interceptors.

import SuperTokens from 'supertokens-react-native';
import axios from "axios";

const apiDomain = 'http://192.168.1.3:3001'; // TODO: change url to your own IP address

SuperTokens.addAxiosInterceptors(axios);
axios.defaults.baseURL = 'http://localhost:3001/auth';
axios.defaults.headers.common.rid = 'emailpassword';
axios.defaults.headers.post['Content-Type'] = 'application/json';
SuperTokens.init({
  apiDomain,
  apiBasePath: '/auth',
});

With this setup, the sign up feature will stilll work as intended on the app and the sign up function is more succinct.

const SIGNUP_PATH = '/signup'

axios.post(SIGNUP_PATH, {
  formFields: [
    {
      id: 'email',
      value: email,
    },
    {
      id: 'password',
      value: password,
    },
]);

Of course, even with that, the code can still be better.

  • The structure of the request body is not safe guarded by Typescript yet.
  • We have not addressed the Promise<any> problem so far. This meant that at the frontend component level, we are not certain of the structure of the response body data to access its properties safely. Hence, let’s do some typing and refactoring!

Refactoring for type-safety

We will be creating types for:

  • Request
    • LoginRequestBody
    • LoginFormField → object in the request body’s formFields array
  • Response
    • UserLoginResult → both sign in and sign up returns same object structure
    • User → object in UserLoginResult
  • Creating the LoginRequestBody
    • UserInputs

For now, I’ll just park all of these types under types/supertokens.ts although you could definitely separate them into different files as well.

export enum FormFieldId {
  email = 'email',
  password = 'password',
}

export type LoginFormField = {
  id: FormFieldId;
  value: string;
};

export type LoginRequestBody = {
  formFields: LoginFormField[];
};

export interface UserInputs {
  [x: string]: string;
}

export type User = {
  id: string;
  email: string;
  timeJoined: number;
};

export type UserLoginResult = {
  user: User;
  id: string;
};

Then, we can create a function that takes in the user inputs as before to create the request body of LoginRequestBody type.

const createLoginRequestBody = (email: string, password: string): LoginRequestBody => {
  return {
        formFields: [
        {
          id: FormFieldId.email,
          value: email,
        },
        {
          id: FormFieldId.password,
          value: password,
        },
      ]
    }
};

Finally, we can type the response body from the resolved axios response, so that the consumer of this function would know exactly how the structure of the response body is like.

This leads to the final signUpUser function being

const signUpUser = ({
  email,
  password,
}: UserInputs): Promise<AxiosResponse<UserLoginResult>> => {
  return axios.post(SIGNUP_PATH, createLoginRequestBody(email, password));
};

And at the component level, we have typescript intellisense to help us get the user’s id safely from the resolved axios response.

<Button
        mode="contained"
        color="#ff9933"
        onPress={() => {
          signUpUser({email, password})
            .then(result => {
              Alert.alert('User created', `${result.data.user.id}`, [
                {text: 'OK', onPress: () => navigator.navigate('Home')},
              ]);
            })
            .catch(err => console.warn(JSON.stringify(err)))
            .finally(() => console.log('Attempted to call SuperTokens API'));
        }}>
        Sign up
</Button>

As you can imagine, adding new functions such as signInUser to call other SuperTokens API will be straight-forward too after our refactoring.

const SIGNIN_PATH = '/signin';

export const signInUser = ({
  email,
  password,
}: UserInputs): Promise<AxiosResponse<UserLoginResult>> => {
  return axios.post(SIGNIN_PATH, createLoginRequestBody(email, password));
};

Final Result

🐙 You can refer to the final result here as well.

api/supertokens.ts

import axios, {AxiosResponse} from 'axios';
import {
  FormFieldId,
  LoginRequestBody,
  UserInputs,
  UserLoginResult,
} from '../types/supertokens';

const SIGNUP_PATH = '/signup';
const SIGNIN_PATH = '/signin';

const createLoginRequestBody = (
  email: string,
  password: string,
): LoginRequestBody => {
  return {
    formFields: [
      {
        id: FormFieldId.email,
        value: email,
      },
      {
        id: FormFieldId.password,
        value: password,
      },
    ],
  };
};

export const signUpUser = ({
  email,
  password,
}: UserInputs): Promise<AxiosResponse<UserLoginResult>> => {
  return axios.post(SIGNUP_PATH, createLoginRequestBody(email, password));
};

export const signInUser = ({
  email,
  password,
}: UserInputs): Promise<AxiosResponse<UserLoginResult>> => {
  return axios.post(SIGNIN_PATH, createLoginRequestBody(email, password));
};

types/supertokens.ts

export enum FormFieldId {
  email = 'email',
  password = 'password',
}

export type LoginFormField = {
  id: FormFieldId;
  value: string;
};

export type LoginRequestBody = {
  formFields: LoginFormField[];
};

export interface UserInputs {
  [x: string]: string;
}

export type User = {
  id: string;
  email: string;
  timeJoined: number;
};

export type UserLoginResult = {
  user: User;
  id: string;
};

That's a wrap folks! 🎉

https://c.tenor.com/eoM1uCVuXtkAAAAM/yay-excited.gif

Thank you for reading, hope you enjoyed the article!

If you find the article awesome, hit the reactions 🧡 and share it 🐦~

To stay updated whenever I post new stuff, follow me on Twitter.

Did you find this article valuable?

Support Estee Tey by becoming a sponsor. Any amount is appreciated!