Handle Ajax requests with Vue 3, Vuex, axios and TypeScript

Practically every application needs to handle Ajax requests and one of the most convenient libraries of recent years that helps with it is axios. I really like axios. There are quite a few different things that need to happen every time a request is made, such as setting Authorization headers, handling errors, etc.

I was trying to come up with the ideal* way of making Ajax requests without repeating the same logic over and over again and keeping the components DRY.

The result:

async () => {
  const response = await new AjaxRequest(requestId)
    .url("/auth/login")
    .body({
      email: email.value,
      password: password.value,
    })
    .post<LoginPayload>();

  if (response) {
    store.commit("storeAccessToken", response.data.token);
  }
}

The way it looks is quite similar to standard axios style of making requests, except that every request is given a requestId that is used as a key to store information in Vuex. Errors (including validation errors) are handled by a centralised error handler, which removes the need to worry about those details every time a request is made.

One interesting detail is that we can leverage TypeScript to create interfaces for the payload that is returned as part of the response.

interface UserPayload {
  email: string
  firstName: string
  lastName: string
  role: string
};

interface TokenPayload {
  accessToken: string
  expiresIn: number
};

interface LoginPayload {
  user: UserPayload,
  token: TokenPayload
};

Since a successful response contains a payload that matches LoginPayload interface we get the benefit of type checking, auto-complete and other goodies:


User payload

Let's dive in

We are going to start with a simple enum that describes the status of the request:

export enum Status {
  PENDING = "pending",
  SUCCESS = "success",
  ERROR = "error",
}

Every request is going to be returned as a Promise:

import { AxiosResponse } from "axios";

export type AjaxPromise<T = any> = Promise<void | AxiosResponse<T>>;

The T is a generic type that we are going to apply to the response payload. In other words, it is what allows to represent the response payload as LoginPayload in the example above.

AjaxRequest class

Here's the meat of the implementation, the AjaxRequest class. The most interesting part is the makeRequest method, which wraps the call to request method provided by axios and returns a Promise.

The makeRequest method is protected, but there is a number of public methods such as post, put, get. These are shortcuts that allow to send a request using a particular HTTP method.

import axios, {
  AxiosError,
  AxiosRequestConfig,
  AxiosResponse,
  Method,
} from "axios";

import { AjaxPromise } from "./ajax-promise.type";
import { ErrorMessage } from "./error-message.interface";
import { Status } from "./status";
import store from "../../store";

export class AjaxRequest {
  private requestId: string;
  private config: AxiosRequestConfig;
  private reject = false;

  constructor(requestId: string, config: AxiosRequestConfig = {}) {
    this.requestId = requestId;
    this.config = config;

    this.setHeaders();
    this.initRequest();
  }

  private setHeaders(): void {
    this.config.headers = {
      ...this.config.headers,
      ...this.jsonHeaders(),
      ...this.authorizationHeaders(),
    };
  }

  private jsonHeaders(): Record<string, string> {
    return {
      Accept: "application/json",
      "Content-type": "application/json",
    };
  }

  private authorizationHeaders(): Record<string, string> {
    const accessToken = store.getters.getAccessToken;
    return accessToken
      ? {
          Authorization: `Bearer ${accessToken}`,
        }
      : {};
  }

  private initRequest(): void {
    store.commit("newRequest", { requestId: this.id() });
  }

  public id(): string {
    return this.requestId;
  }

  public url(path: string): AjaxRequest {
    this.config.url = `${process.env.VUE_APP_BASE_URL}${path}`;
    return this;
  }

  public body(data: any): AjaxRequest {
    this.config.data = data;
    return this;
  }

  public rejectOnError(): AjaxRequest {
    this.reject = true;
    return this;
  }

  public send<T>(method: Method = "get"): AjaxPromise<T> {
    this.config.method = method;
    return this.makeRequest<T>();
  }

  public get<T>(): AjaxPromise<T> {
    this.config.method = "get";
    return this.makeRequest<T>();
  }

  public post<T>(): AjaxPromise<T> {
    this.config.method = "post";
    return this.makeRequest<T>();
  }

  public put<T>(): AjaxPromise<T> {
    this.config.method = "put";
    return this.makeRequest<T>();
  }

  public delete<T>(): AjaxPromise<T> {
    this.config.method = "delete";
    return this.makeRequest<T>();
  }

  protected updateStatus(status: Status): void {
    store.commit("updateAjaxStatus", { requestId: this.id(), status });
  }

  protected makeRequest<T>(): AjaxPromise<T> {
    this.updateStatus(Status.PENDING);

    return axios
      .request<T>(this.config)
      .then((response: AxiosResponse) => {
        this.updateStatus(Status.SUCCESS);
        return response;
      })
      .catch((error: AxiosError) => {
        this.updateStatus(Status.ERROR);
        this.errorHandler(error);

        // The errors are already handled above, however we can optionally rethrow
        // the error, so that it can be handled in the next catch block again
        if (this.reject) {
          throw error;
        }
      });
  }

  protected errorHandler(error: AxiosError): void {
    if (error.response) {
      switch (error.response.status) {
        case 422:
          this.parseValidationErrors(error.response);
          break;
        default:
          this.parseError(error.response);
          break;
      }
    }
  }

  protected parseError(response: AxiosResponse): void {
    store.commit("storeError", {
      requestId: this.id(),
      error: response.data?.message || "error.generic",
    });
  }

  protected parseValidationErrors(response: AxiosResponse): void {
    const errors = {} as Record<string, string[]>;

    response.data.message.map((m: ErrorMessage) => {
      errors[m.property] = Object.values(m.constraints);
    });

    store.commit("storeValidationErrors", {
      requestId: this.id(),
      errors,
    });
  }
}

ErrorMessage interface:

export interface ErrorMessage {
  property: string;
  constraints: Record<string, string>;
}

The AjaxRequest class contains a centralised error handler that should take care of all common errors that may happen when a request is made. For all those situations, where there's a need for additional error handling it can be done by appending rejectOnError() to the method chain:

async () => {
  const response = await new AjaxRequest(requestId)
    .url("/auth/login")
    .body({
      email: email.value,
      password: password.value,
    })
    .rejectOnError()
    .post()
    .catch((error) => {
      // Any additional error handling that isn't covered by the
      // centralised error handler (this is completely optional)
    });
}

Request validation

Considering that all validation errors end up in the Vuex store we can quite easily arrive at a component that displays validation errors for any request id and attribute name:

<Validation
  :request-id="requestId"
  attribute="email"
  v-slot="{ hasErrors }"
>
  <input
    id="email"
    v-model="email"
    type="text"
    :class="{ 'invalid': hasErrors }"
  />
</Validation>

This is implemented in the following way:

<template>
  <div>
    <slot :has-errors="hasErrors"></slot>
    <ul v-if="hasErrors">
      <li v-for="error in errors" :key="error">
        {{ error }}
      </li>
    </ul>
  </div>
</template>

<script lang="ts">
import { defineComponent, computed } from "vue";
import { useStore } from "vuex";

export default defineComponent({
  props: {
    requestId: {
      type: String,
      required: true,
    },
    attribute: {
      type: String,
      required: true,
    },
  },
  setup(props) {
    const store = useStore();

    const errors = computed(() =>
      store.getters.getValidationErrors(
      	props.requestId,
      	props.attribute
      )
    );

    const hasErrors = computed(() => errors.value.length > 0);

    return {
      errors,
      hasErrors
    };
  },
});
</script>

Vuex store

Finally, an example of a Vuex store that manages state for each Ajax request. I decided to keep all interfaces in a separate file:

import { Status } from "../../services/ajax/status";

export interface RequestState {
  validationErrors: Record<string, string[]>;
  error: string | null;
  status: string | null;
}

export interface State {
  requests: Record<string, RequestState>;
}

export interface NewRequestPayload {
  requestId: string;
}

export interface ErrorPayload {
  requestId: string;
  error: string;
}

export interface ValidationErrorsPayload {
  requestId: string;
  errors: Record<string, string[]>;
}

export interface AjaxStatusPayload {
  requestId: string;
  status: Status;
}

The ajax module is responsible for request state management:

import { Status } from "../../services/ajax/status";

import {
  AjaxStatusPayload,
  ErrorPayload,
  NewRequestPayload,
  State,
  ValidationErrorsPayload,
} from "./ajax.interface";

const state: State = {
  requests: {},
};

const getters = {
  getValidationErrors: (state: State) => (
    requestId: string,
    attribute: string
  ): string[] => {
    return state.requests[requestId]?.validationErrors[attribute] || [];
  },
  getError: (state: State) => (requestId: string): string | null => {
    return state.requests[requestId]?.error || null;
  },
  ajaxPending: (state: State) => (requestId: string): boolean => {
    return state.requests[requestId]?.status === Status.PENDING;
  },
};

const mutations = {
  newRequest(state: State, payload: NewRequestPayload): void {
    state.requests[payload.requestId] = {
      validationErrors: {},
      error: null,
      status: null,
    };
  },
  storeValidationErrors(state: State, payload: ValidationErrorsPayload): void {
    state.requests[payload.requestId].validationErrors = payload.errors;
  },
  storeError(state: State, payload: ErrorPayload): void {
    state.requests[payload.requestId].error = payload.error;
  },
  updateAjaxStatus: (state: State, payload: AjaxStatusPayload): void => {
    state.requests[payload.requestId].status = payload.status;
  },
};

const ajax = {
  state,
  getters,
  mutations,
};

export default ajax;

The auth module for now is simply the place to keep the access token. I include it for completeness sake.

Once the token is available in the Vuex store it's going to be sent in the Authorization header every time a request is made.

export interface AccessTokenRecord {
  accessToken: string;
}

export interface State {
  token: AccessTokenRecord | null;
}
import { State, AccessTokenRecord } from "./auth.interface";

const state: State = {
  token: null,
};

const getters = {
  getAccessToken: (state: State): string => {
    return state.token?.accessToken || "";
  },
};

const mutations = {
  storeAccessToken(state: State, payload: AccessTokenRecord): void {
    state.token = payload;
  },
};

const auth = {
  state,
  getters,
  mutations,
};

export default auth;
* This approach probably isn't perfect as I'm still learning TypeScript, but it works well enough as a proof of concept. It definitely made me appreciate the benefits of the type system in TS.