DEV Community

Cover image for Managing API layers in Vue.js with TypeScript
Blind Kai
Blind Kai

Posted on • Updated on

Managing API layers in Vue.js with TypeScript

Motivation

Almost every Single-Page Application at some point needs to get some data from the backend. Sometimes there are several sources of data like REST APIs, Web Sockets etc. It's important to manage the API layer in the right way to make it simple and easy to use in any place of your application no matter if it's store, component or another type of source file.

TLDR

If you already have some experience in development and want to check the solution here is the FancyUserCard example. If some things would be hard to understand feel free to check the detailed step-by-step path.

Bad

An example of API calls in componentPerforming API calls in the component is bad because:

  • You make your components large and filled with logic that has nothing to do with the component itself which violates SRP;
  • Same API methods could be used in different components which causes code duplication and violates DRY;
  • You are importing dependencies globally and it violates the DI principle;
  • Whenever API changes, you need to manually change every method that is needed to be modified.

Good

To make things work better we need to slightly change our code and move all the API calls into a separate place.

users.api.ts

Users API layer fileIn this case we:

  • Have one single AxiosInstance that is configured to work with /users API branch and our code becomes modular;
  • Have all methods located in one place so it's easier to make changes and to reuse them in different components without duplicating code;
  • Handle the successful request as well as request failure and make us able to work with both error and data object depending on request status;
  • Provide a standardized response return type for each method so we can work with them in one way.

FancyUserCard.vue

FancyUserCard component that imports API methodsAnd in our component:

  • We are not dealing with the HTTP layer at all so our component is only responsible for rendering data that comes from the API layer;
  • Methods return both errors and data so we can notify your user if something went wrong or simply use data that was returned by a method.

Advanced

API methods encapsulated within a classSome final changes:

  • The API call method was moved to reduce code duplication and all the methods are called using this private method.

Some other ideas

The approach shown above is enough to handle standard API layer workflow. If you want to make it even more flexible you could think about implementing some ideas below:

Creating abstraction over HTTP layerCreating abstraction over HTTP layerAbout the idea:

In the example, you can see that now we have an interface for our HttpClient so we could have as many implementations as we need. It works if we have different HTTP clients like axios, fetch, ky and if we will need to migrate from one to another we would simply need to rewrite our HttpClient implementation in one place and it will be applied automatically in any place where we use our service;

Create a factoryCreate a factoryAbout the idea:

If you have few different data sources you could use some sort of factory to create the instance with needed implementation without an explicit class declaration. In this case, you just need to provide a contract interface and then implement each API method as you want.

About the problem

As you already know, dealing with API calls in your components is harmful because whenever the changes come you have plenty of work to do to maintain your code in the working state. Also, it can be pretty challenging to test components and API because they are directly and deeply coupled. We want to avoid those things while writing code so let's get through the example.

Example

This is the code for the initial example of an API call. For simplicity let's omit other code and keep attention only on the method itself.

axios
  .get<User>(`https://api.fancy-host.com/v1/users/${this.userId}`)
  .then((response) => {
    this.user = response.data;
  })
  .catch((error) => {
    console.error(error);
  });
Enter fullscreen mode Exit fullscreen mode

As you can already see, we're accessing the component data() directly and use global axios which forces us to type more code for setting the request configuration.

TODO list

  1. Migrate the code to a separate method;
  2. Move from then syntax to async/await;
  3. Setup axios instance;
  4. Manage methods return type;
  5. Incapsulate the method in Class.

Refactoring

1. Migrate the code to a separate method

To start with, lest move our code to the separate file and simply export a function that accepts userId as input parameter and return user object if the call was successful:

export function getUser(userId: number) {
  axios
  .get<User>(`https://api.fancy-host.com/v1/users/${userId}`)
  .then((response) => {
    return response.data;
  })
  .catch((error) => {
    console.error(error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Already an improvement! Now we can import this function whenever we need to get User. We just need to specify the userId and we're ready to go.

2. Move from then syntax to async/await

In real world there are often situations when you need to make sequential calls. For example, when you fetch user you probably want to get information about posts or comments related to user, right? Sometimes you want to perform requests in parallel and it can be really tricky if we're talking about .then implementation. So why won't we make it better?

export async function getUser(userId: number): Promise<User | undefined> {
  try {
    const { data } = await axios.get<User>(`https://api.fancy-host.com/v1/users/${userId}`);
    return data;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, now we are providing additional typings and using await to stop our code from running until the API call finishes. remember that you're able to use await only inside the async function.

3. Setup axios instance;

Okay, so now the longest line is the one with the end-point URL. Your server host is probably not going to change often and it's better to keep your API branch set up in one place so let's get into:

const axiosInstance = axios.create({
  baseURL: "https://api.fancy-host.com/v1/users"
});

export async function getUser(userId: number): Promise<User | undefined> {
  try {
    const { data } = await axiosInstance.get<User>(`/users/${userId}`);
    return data;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Much better. Now if your /users API branch will change, you could simply rewrite it in the instance configuration and it will be applied to every call made using this AxiosInstance. Also, now you could use something called Interceptors which allows you to make some additional changes to requests/responses or perform logic when a request is made or response is back. Check out the link to get more details!

4. Manage methods return type

What if I will say to you that your user doesn't understand if (and why) something went wrong .. until! Until you provide some information about "what went wrong". UX is really important to keep your user happy and make the workflow better at all. So how are we going to do that? Simply by returning both data and error from our API call. You could also return as many things as you need (if you need them, right?):

export type APIResponse = [null, User] | [Error];

export async function getUser(userId: number): Promise<APIResponse> {
  try {
    const { data } = await axiosInstance.get<User>(`/${userId}`);
    return [null, data];
  } catch (error) {
    console.error(error);
    return [error];
  }
}
Enter fullscreen mode Exit fullscreen mode

And how it will look when we use it, for example in our created() callback:

async created() {
  const [error, user] = await getUser(this.selectedUser);

  if (error) notifyUserAboutError(error);
  else this.user = user;
}
Enter fullscreen mode Exit fullscreen mode

So in this case, if any error happens, you would be able to react to this and perform some actions like pushing an error notification, or submit a bug report or any other logic you put in your notifyUserAboutError method. Elsewise, if everything went okay, you could simply put the user object into your Vue component and render fresh information.

Also, if you need to return additional information (for example status code to indicate if it is 400 Bad Request or 401 Unautorized in case of failed request or if you want to get some response headers if everything was okay), you could add an object in your method return:

export type Options = { headers?: Record<string, any>; code?: number };

export type APIResponse = [null, User, Options?] | [Error, Options?];

export async function getUser(userId: number): Promise<APIResponse> {
  try {
    const { data, headers } = await axiosInstance.get<User>(`/${userId}`);
    return [null, data, { headers }];
  } catch (error) {
    console.error(error);
    return [error, error.response?.status];
  }
}
Enter fullscreen mode Exit fullscreen mode

And usage:

  async created() {
    const [error, user, options] = await getUser(this.selectedUser);

    if (error) {
      notifyUserAboutError(error);

      if (options?.code === 401) goToAuth();
      if (options?.code === 400) notifyBadRequest(error);
    } else {
      this.user = user;

      const customHeader = options?.headers?.customHeader;
    }
  }
Enter fullscreen mode Exit fullscreen mode

As you can see, your requests become more and more powerful but at the same time, you can make your components free from that logic and work only with those details you need.

5. Incapsulate the method in Class

And now there is time for the final touch. Our code is already doing a great job but we can make it even better. For example, there are cases when we want to test how our components interact with other layers. At the same time, we don't want to perform real requests and it's enough to ensure that we make them correctly at all. To achieve this result we want to be able to mock our HTTP client. To make it possible, we want to "inject" a mocked instance into our module and it's hard to imagine a better way to do that than with Class and its constructor.

export class UserService {
  constructor(private httpClient: AxiosInstance) {}

  async getUser(userId: number): Promise<APIResponse> {
    try {
      const { data } = await this.httpClient.get<User>(`/${userId}`);
      return [null, data];
    } catch (error) {
      console.error(error);
      return [error];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And the usage:

const axiosInstance = axios.create({
  baseURL: "https://api.fancy-host.com/v1/users"
});

export const userService = new UserService(axiosInstance);
Enter fullscreen mode Exit fullscreen mode

In this case, you don't expose your AxiosInstance and provide access only thru your service public API.

Conclusions

Hope that this article was useful for you. Do not hesitate to leave a comment if you have some other ideas or if there are any questions about the content of this post. I will update this post with detailed information about the problem, the solutions and the refactoring process soon.
Cheers!

Top comments (10)

Collapse
 
seagerjs profile image
Scott Seager

This is a fantastic breakdown of responsibilities and separation of concerns using sound design principles.

Proper abstractions and the use of encapsulation are downright essential for any non-trivial project, and even when a project is trivial it is still an opportunity to exercise good architecture as reinforcement for cases where things aren’t as easy.

The API interface code is easier to understand, the component logic is easier to understand, and all of it is significantly more testable.

Nicely done.

Collapse
 
blindkai profile image
Blind Kai

Thank you for a detailed and warm feedback!
My pleasure.

Collapse
 
krisb profile image
Krisb

Hello. I love your solution.
I see one problem in my project using your approach. I cannot set any dynamic data in headers like data form pinia store or from router.currentRoute.value.params ...

Because this kind of files are loaded before router is initiated or Pinia being ready :(

How should I dealing with that kind of problems?

Thank you one more time!

Collapse
 
gabrielhangor profile image
Gabriel Khabibulin

Your implementation looks great and will certainly use this approach for my new project. However, how would you handle loading state/logic to display a spinner or smth in such a case? I've tried to figure it out by myself but couldnt. Thank you.

Collapse
 
blindkai profile image
Blind Kai

If we're talking about Axios and file loading - there is a special callback for this:
onUploadProgress in Request Config.

Talking about loaders, you could implement an interceptor for this case.

Collapse
 
gabrielhangor profile image
Gabriel Khabibulin

Thank you for the reply!

Collapse
 
jamalroger profile image
BELHARRADI JAMAL

The prosess is too long to get data from api.
It's can do it easily by merge api and services and same class

Collapse
 
blindkai profile image
Blind Kai • Edited

In my honest opinion, the performance impact from the additional infrastructure code is minimal. At the same time, you probably don't want to mix things if they are responsible for different things because of SRP. Refactoring and maintaining code is much easier when code is layered.
So I can't agree with that.
Also, if you're working in a team, you probably work with the code some other developer had written. You would be surprised if you will find the UserServiceAPI class with all the logic inside. Especially if you only need to get a user using id.
SOLID principles were formed by reasons and to solve problems.

Collapse
 
jamalroger profile image
BELHARRADI JAMAL

Good point, but still too long in opinion usually i used ServiceApi directly from the controller to have full control of data.

Thread Thread
 
blindkai profile image
Blind Kai

If you're sure that things won't change and you won't need to change them - it's fine.
If there are several developers which use the same public API, you probably want things to work the same for everything to keep the style it a good state.