Axios + Vue.js 3 + Pinia, a “comfy” configuration you can consider for an API REST

bugintheconsole

Israel Díaz Zapata

Posted on October 25, 2023

Axios + Vue.js 3 + Pinia, a “comfy” configuration you can consider for an API REST

Let me share with you with this guide a comfortable / easy configuration for Axios in a Vue.js 3 app with Pinia as our state management. The general idea is managing an easy way to add and use different endpoints when you are working with an API REST.

Scope of this guide:
This guide is not a tutorial to learn Pinia or Axios.
All the backend solution is not covered in this guide.

Sections:

  1. Project setup
  2. Install Axios and Pinia. Axios and Pinia configuration
  3. Separate the services in our Vue.js app
  4. Integrate the services with the Pinia stores
  5. Finishing the services
  6. Conclusion

1. Project setup

As a default Vuejs 3 app now in these days, Vite is the way to go. Here are the commands. I will use yarn.
npm create vite@latest
or
yarn create vite

After run one of the above commands, select Vue

Selecting Vue option in the terminal

Then, select JavaScript or TypeScript. I will use TypeScript

Selecting typescript option in the terminal

Finally, go to the new project created (folder) and install the dependencies with "npm install" or "yarn".
After the installation, create an .env file in the root folder of your project to configure some special variables, for example.

//.env file
VITE_API_ENDPOINT=http://localhost:8080/api/v1/
Enter fullscreen mode Exit fullscreen mode

This variable will be the URL for our backend API


2. Installing Axios and Pinia. Axios and Pinia configuration

Axios is useful to handle http request and Pinia is our global state management

#Installing dependencies
yarn add axios pinia
or
npm install axios pinia

2.a Pinia configuration

To use Pinia in all your app, first we need to create the "root Store" that is a Pinia instance that we need to pass it in our app. We have to add it as a plugin in the main.ts file

// main.ts file
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import { createPinia } from "pinia";

const pinia = createPinia();
createApp(App).use(pinia).mount("#app");
Enter fullscreen mode Exit fullscreen mode

2.b Axios configuration

We will configure Axios in a way we can use a common URL for our api and from there, we will add all the new endpoints that we are going to use for the project.

First, create a "services" folder inside the "src" folder in your app. It is called "services" as a simple convention. Then we will create two files inside the "services" folder. The "api.ts" and "types.ts" files.

The "api.ts" file will be useful to configure our axios instance

The "types.ts" file will be useful to define our main types

// api.ts file
import axios from 'axios';
const instance = axios.create({
  baseURL: import.meta.env.VITE_API_ENDPOINT,
});
export default instance
Enter fullscreen mode Exit fullscreen mode

In this "api.ts" file we will have the base configuration for all our endpoints, and we will be able to use the "instance" variable to build all the endpoint that we need for our project. For this guide, this is the minimum configuration that we need.

//types.ts file 
export type APIResponse<T> = {
  success: boolean
  content: T;
  status?: number;
}
Enter fullscreen mode Exit fullscreen mode

This type will represent all the responses body for our different endpoints, as you can see is generic due to the fact that our responses will be different depending on the endpoint that we use.


3. Separate the services in our Vue.js app

In this example, the project will be a basic University. We will have:

  • Teachers
  • Students
  • Schools
  • Courses

Now, let's create another folder inside our "services" folder call "schools". In this folder we will create two more files, files "types.ts" and "index.ts".

The "types.ts" will be the file for all our types, and the "index.ts" file will be the place where we create the endpoints for the schools.

Our "types.ts" file could look like this

export type School = {
  id: number;
  name: string;
  description: string;
};

export type InputCreateSchool = {
  id: number;
  name: string;
  description: string;
};

export type InputUpdateSchool = {
  id: number;
  description: string;
};
Enter fullscreen mode Exit fullscreen mode

We have the School type to represent the list of schools. The InputCreateSchool type is the body of our JSON when we have to send this body through a form to the backend, and the InputUpdateSchool type to represent the body of our form when we have to update the school information.

Now in the "index.ts" file we can use our new types like this

// the axios instance and types
import http from "../api";
import { APIResponse } from "../types";
import { School, InputCreateSchool, InputUpdateSchool } from "./types";

async function getSchools() {
  return await http.get<APIResponse<School[]>>("school");
}

async function deleteSchool(id: number) {
  return await http.delete<APIResponse<boolean>>(`school/${id}`);
}

async function createSchool(input: InputCreateSchool) {
  return await http.post<APIResponse<School>>("school", input);
}

async function updateSchool(input: InputUpdateSchool) {
  return await http.put<APIResponse<boolean>>("school", input);
}

export default {
  getSchools,
  createSchool,
  updateSchool,
  deleteSchool,
};
Enter fullscreen mode Exit fullscreen mode

Perfect,as you can see at the beginning we are importing the axios instance that has our base URL, then we are importing the main type for all our responses and finally the specific types for our "school" endpoint.

The functions represent our CRUD for our "school" endpoint, some of them receives an argument for the http method that they are going to use with its specific input.

Finally, we export these functions to use it in another step of this guide, before we go there, here there is a picture of how is our folder structure so far

Picture of the current folder structure so far with the school services created

Now, let's use the new functions of our new endpoint. To do this, let's create another file in the "services" folder called "index.ts". The main purpose of this file is to be the only place where we can load all our different services.

//services/index.ts
import schoolController from "./schools";

export const API = {
  schools: schoolController,
};
Enter fullscreen mode Exit fullscreen mode

As you can see, here we load all the functions from the "schools" folder with the name of "schoolController", I use the "controller" name convention due to Express.js. In Express.js is really common to call controllers to all the functions that we are using for the routes. It's like a habit.

After the importation, we create the constant that we are going to be using to use all the functions for all our endpoints in our project. It's called "API" because it is cool. Here there is a picture with our folder structure so far.

Picture showing the current folder structure so far with the services updated

Picture with the API constants updated


4. Integrate the services with the Pinia stores

In this section, we will be using our recent "API" constant to work with all the endpoints we have so far.

First, let's prepare the place for our Pinia stores. Let's create a folder inside our "src" root folder called "store" as a good convention, then another folder inside the "store" folder called "schools". Inside this last folder, we are going to create a file called "index.ts" to create our store.

With Pinia, there are two ways to create a store, in an object or with a callback. I will use the second options as it's like the Composition Api

// store/schools/index.
import { defineStore } from "pinia";
import { ref } from 'vue';
import { School } from "../../services/schools/types";

export const useSchoolStore = defineStore("schoolStore", () => {

  const schools = ref<School[]>([]);

  function initSchools(data: School[]) {
    schools.value = data;
  }

  function removeSchool(id: number) {
    const idx = schools.value.findIndex(s => s.id === id);
    if (idx === -1) return;
    schools.value.splice(idx , 1);
  }

  return {
    schools,
    initSchools,
    removeSchool,
  };
});
Enter fullscreen mode Exit fullscreen mode

As you can see in the code, for now we have created the "schools" state to store all our schools and some functions to manipulate this state. And now, how do we get the schools from our backend?.

A clean way to do it is using the concept of vuex's actions. Do you remember the actions in Vuex?

The actions in Vuex allows committing mutations after asynchronous operations. Do not mutate the state directly, this responsibility is for the mutations

Another thing to know is that the vuex's actions are triggered with dispatcher methods, because of that our asynchronous operations will have "dispatch" in the name as a convention.

With the above mentioned, we will create dispatchers for CRUD operations.

Thanks to Typescript and our "API" constant, we have easily autocompletion of our methods for our "school" endpoints

Picture showing the intelisense autocompletition

Finally, our store could look something like this.

import { defineStore } from "pinia";
import { ref } from "vue";
import {
  InputCreateSchool,
  InputUpdateSchool,
  School,
} from "../../services/schools/types";
import { APIResponse } from "../../services/types";
import { API } from "../../services";
import { AxiosError } from "axios";

export const useSchoolStore = defineStore("schoolStore", () => {
  const schools = ref<School[]>([]);

  function initSchools(data: School[]) {
    schools.value = data;
  }

  function addNewSchool(school: School) {
    schools.value.push(school);
  }

  function removeSchool(id: number) {
    const idx = schools.value.findIndex((s) => s.id === id);
    if (idx === -1) return;
    schools.value.splice(idx, 1);
  }

  async function dispatchGetSchools(): Promise<APIResponse<null>> {
    try {
      const { status, data } = await API.school.getSchools();
      if (status === 200) {
        initSchools(data.content);
        return {
          success: true,
          content: null,
        };
      }
    } catch (error) {
      const _error = error as AxiosError<string>;
      return {
        success: false,
        status: _error.response?.status,
        content: null,
      };
    }
    return {
      success: false,
      content: null,
      status: 400,
    };
  }

  async function dispatchCreateSchool(
    input: InputCreateSchool
  ): Promise<APIResponse<null>> {
    try {
      const { status, data } = await API.school.createSchool(input);
      if (status === 200) {
        addNewSchool(data.content);
        return {
          success: true,
          content: null,
        };
      }
    } catch (error) {
      const _error = error as AxiosError<string>;
      return {
        success: false,
        status: _error.response?.status,
        content: null,
      };
    }
    return {
      success: false,
      content: null,
      status: 400,
    };
  }

  async function dispatchDeleteSchool(id: number): Promise<APIResponse<null>> {
    try {
      const { status } = await API.school.deleteSchool(id);
      if (status === 200) {
        removeSchool(id);
        return {
          success: true,
          content: null,
        };
      }
    } catch (error) {
      const _error = error as AxiosError<string>;
      return {
        success: false,
        status: _error.response?.status,
        content: null,
      };
    }
    return {
      success: false,
      content: null,
      status: 400,
    };
  }

  async function dispatchUpdateSchool(
    input: InputUpdateSchool
  ): Promise<APIResponse<null>> {
    try {
      const { status } = await API.school.updateSchool(input);
      if (status === 200) {
        return {
          success: true,
          content: null,
        };
      }
    } catch (error) {
      const _error = error as AxiosError<string>;
      return {
        success: false,
        status: _error.response?.status,
        content: null,
      };
    }
    return {
      success: false,
      content: null,
      status: 400,
    };
  }

  return {
    schools,
    initSchools,
    removeSchool,
    dispatchGetSchools,
    dispatchCreateSchool,
    dispatchDeleteSchool,
    dispatchUpdateSchool,
  };
});
Enter fullscreen mode Exit fullscreen mode

Nice, we have created the CRUD operations with dispatcher functions. As you can see, these dispatchers commits "mutations" after the asynchronous operation is resolved and mutate the state. Notice that each dispatcher returns a promise with a specific type, so we can respect this contract when we use these functions.

The errors are being controlled with a try-catch block, the error's response can be customized as you want. For this guide, this is enough.

Here there is a picture of our store so far.

Pícture of our store folder so far

Now we are ready to use these dispatchers, in a Schools component for example we can do this.

<template>
  <div>
    <h1>The list of schools</h1>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from "vue";
import { useSchoolStore } from "../store/schools";

const schoolStore = useSchoolStore();

onMounted(async () => {
  const { success, status } = await schoolStore.dispatchGetSchools();

  if (!success) {
    alert("Ups, something happened 🙂");
    console.log("Api status ->", status);
  }
});
</script>

<style scoped></style>
Enter fullscreen mode Exit fullscreen mode

In this case, we are loading our schools when the component is mounted, since the dispatcher returns an object with useful properties, we can use them to handle some messages for our users and so on.


5. Finishing the services

To end this guide, let's create the rest of our services to see our "API" constant in action.

  • Courses service
// the index.ts file
import http from "../api";
import { APIResponse } from "../types";
import { Course, InputCreateCourse, InputUpdateCourse } from "./types";

async function getCourses() {
  return await http.get<APIResponse<Course[]>>("course");
}

async function deleteCourse(id: number) {
  return await http.delete<APIResponse<boolean>>(`course/${id}`);
}

async function createCourse(input: InputCreateCourse) {
  return await http.post<APIResponse<Course>>("course", input);
}

async function updateCourse(input: InputUpdateCourse) {
  return await http.put<APIResponse<boolean>>("course", input);
}

export default {
  getCourses,
  createCourse,
  updateCourse,
  deleteCourse,
};
Enter fullscreen mode Exit fullscreen mode
// the types.ts file 
export type Course = {
  id: number;
  title: string;
  description: string;
  schoolID: number;
};

export type InputCreateCourse = {
  title: string;
  description: string;
  schoolID: number;
};

export type InputUpdateCourse = {
  id: number;
  title: string;
  description: string;
  schoolID: number;
};
Enter fullscreen mode Exit fullscreen mode
  • Teachers service
// the index.ts file
import http from "../api";
import { APIResponse } from "../types";
import { Teacher, InputCreateTeacher, InputUpdateTeacher } from "./types";

async function getTeachers() {
  return await http.get<APIResponse<Teacher[]>>("teacher");
}

async function deleteTeacher(id: number) {
  return await http.delete<APIResponse<boolean>>(`teacher/${id}`);
}

async function createTeacher(input: InputCreateTeacher) {
  return await http.post<APIResponse<Teacher>>("teacher", input);
}

async function updateTeacher(input: InputUpdateTeacher) {
  return await http.put<APIResponse<boolean>>("teacher", input);
}

export default {
  getTeachers,
  createTeacher,
  updateTeacher,
  deleteTeacher,
};
Enter fullscreen mode Exit fullscreen mode
// the types.ts file 
export type Teacher = {
  id: number;
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
  courseID: number;
};

export type InputCreateTeacher = {
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
  courseID: number;
};

export type InputUpdateTeacher = {
  id: number;
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
  courseID: number;
};
Enter fullscreen mode Exit fullscreen mode
  • Students service
//the index.ts file 
import http from "../api";
import { APIResponse } from "../types";
import { Student, InputCreateStudent, InputUpdateStudent } from "./types";

async function getStudents() {
  return await http.get<APIResponse<Student[]>>("student");
}

async function deleteStudent(id: number) {
  return await http.delete<APIResponse<boolean>>(`student/${id}`);
}

async function createStudent(input: InputCreateStudent) {
  return await http.post<APIResponse<Student>>("student", input);
}

async function updateStudent(input: InputUpdateStudent) {
  return await http.put<APIResponse<boolean>>("student", input);
}

export default {
  getStudents,
  createStudent,
  updateStudent,
  deleteStudent,
};
Enter fullscreen mode Exit fullscreen mode
//the types.ts file
export type Student = {
  id: number;
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
};

export type InputCreateStudent = {
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
};

export type InputUpdateStudent = {
  id: number;
  firstName: string;
  lastName: string;
  age: number;
  schoolID: number;
};
Enter fullscreen mode Exit fullscreen mode

Now, our "services/index.ts" file has more controllers. Take a look:

import schoolController from "./schools";
import courseController from "./courses";
import studentsController from "./students";
import teachersController from "./teachers";

export const API = {
  school: schoolController,
  course: courseController,
  student: studentsController,
  teacher: teachersController,
};
Enter fullscreen mode Exit fullscreen mode

Here there is a picture of our services folder so far.

Api constants updated with the new services

If we check our "API" constant in the last dispatcher, now we have this.

Picture showing services autocompletition

The controllers are in our hand, and each of them has their respective functions, for example.

Picture demonstrating services autocompletion

Now it is up to you to keep creating your stores, it is a good practice indeed.


6. Conclusion

  • With these abstractions, it's easy to add more and more services

  • Update any endpoint's URL it's not a problem, you just need to go to a specific file and change it once.

  • You have a complete control of all your endpoints with a single constant

  • It's easy to replicate how the dispatchers works for any endpoint you need, even with more complex functionalities.

💖 💪 🙅 🚩
bugintheconsole
Israel Díaz Zapata

Posted on October 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related