DEV Community

Cover image for Path To A Clean(er) React Architecture - A Shared API Client
Johannes Kettmann
Johannes Kettmann

Posted on • Originally published at profy.dev

Path To A Clean(er) React Architecture - A Shared API Client

The unopinionated nature of React is a two-edged sword:

  • On the one hand you get freedom of choice.
  • On the other hand many projects end up with a custom and often messy architecture.

This article is the beginning of a series about software architecture and React apps where we take a code base with lots of bad practices and refactor it step by step.

Here we start by extracting common API request configuration from components to a shared API client. The changes to the code are small, but the impact on maintainability over the long run will be huge. Let’s start by looking at a code example.

Table Of Contents

  1. Bad code: Hard-coded API request configuration all over the place
  2. Why is this code bad?
  3. Solution: A shared API client
  4. Why is this code better?
  5. Next refactoring steps: Introducing an API layer
  6. PS: Shared API client with fetch

As an alternative to the text based content below you can also watch this video.

Bad code: Hard-coded API request configuration all over the place

Here is one component of the example project: a component that fetches data from two API endpoints and renders the data.

This is just one component but there are many more API calls in the code.

import axios from "axios";
import { useEffect, useState } from "react";
import { useParams } from "react-router";

import { LoadingSpinner } from "@/components/loading";
import { ShoutList } from "@/components/shout-list";
import { UserResponse, UserShoutsResponse } from "@/types";

import { UserInfo } from "./user-info";

export function UserProfile() {
  const { handle } = useParams<{ handle: string }>();

  const [user, setUser] = useState<UserResponse>();
  const [userShouts, setUserShouts] = useState<UserShoutsResponse>();
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    axios
      .get<UserResponse>(`/api/user/${handle}`)
      .then((response) => setUser(response.data))
      .catch(() => setHasError(true));

    axios
      .get<UserShoutsResponse>(`/api/user/${handle}/shouts`)
      .then((response) => setUserShouts(response.data))
      .catch(() => setHasError(true));
  }, [handle]);

  if (hasError) {
    return <div>An error occurred</div>;
  }

  if (!user || !userShouts) {
    return <LoadingSpinner />;
  }

  return (
    <div className="max-w-2xl w-full mx-auto flex flex-col p-6 gap-6">
      <UserInfo user={user.data} />
      <ShoutList
        users={[user.data]}
        shouts={userShouts.data}
        images={userShouts.included}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why is this code bad?

This code uses the same setup for each API request (here the base path /api as example).

The requests use the same base path all over the place.

The problem: if we changed the API to e.g. use a different version in the path like /api/v2 we’d have to change every request. If we wanted to add an application-wide header (e.g. for authentication) we’d have to add it everywhere.

This is unmaintainable especially in larger projects with lots of API calls.

Additionally any change to API requests in general would come with a risk that we’d miss updating one of the requests and break parts of the application.

But what’s the solution?

Solution: A shared API client

Sharing the foundational request config between all requests is key. To achieve this we first extract all axios references to a shared file.

// /src/api/client.ts

import axios from "axios";

export const apiClient = axios.create({
  baseURL: "/api",
});

Enter fullscreen mode Exit fullscreen mode

This is just a simple example. We could add more common configuration to this api client like application-wide headers or logic that passes a token from local storage to the request.

Anyway.

Now we use our new API client in the component.

import { apiClient } from "@/api/client";

...

export function UserProfile() {
  const { handle } = useParams<{ handle: string }>();

  const [user, setUser] = useState<UserResponse>();
  const [userShouts, setUserShouts] = useState<UserShoutsResponse>();
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    apiClient
      .get<UserResponse>(`/user/${handle}`)
      .then((response) => setUser(response.data))
      .catch(() => setHasError(true));

    apiClient
      .get<UserShoutsResponse>(`/user/${handle}/shouts`)
      .then((response) => setUserShouts(response.data))
      .catch(() => setHasError(true));
  }, [handle]);

  ...
}

Enter fullscreen mode Exit fullscreen mode

You’re right, this doesn’t seem like a big change, so…

Why is this code better?

Let’s imagine this scenario: the API base path changes from /api to /api/v2.

This is common when breaking changes have to be introduced in the API. With our shared API client we only need to update a single line in the code.

// /src/api/client.ts

import axios from "axios";

export const apiClient = axios.create({
  baseURL: "/api/v2",
});
Enter fullscreen mode Exit fullscreen mode

And as mentioned, we could easily add app-wide headers or even middleware to e.g. copy a token from local storage to the request headers.

Not only that! We also started to isolate the UI code from the API. Fetching data became a bit simpler:

apiClient.get<UserResponse>(`/user/${handle}`)
Enter fullscreen mode Exit fullscreen mode

Now the UI

  1. doesn’t know about the base path /api
  2. doesn’t know that we use Axios for data fetching.

These are implementation details that the UI shouldn’t care about.

While these are important changes we’re far from done yet.

Next refactoring steps: Introducing an API layer

Ok, the shared API client already improved the code big time.

But if you think the code still doesn’t look good, I agree with you. Using a shared API client we started to decouple our UI from API related code.

But look how much knowledge about the API is still inside the component!

The UI code still has a lot of knowledge about the API requests like request method, response data type, endpoint path, and parameter handling.

In the upcoming weeks, we’ll continue this refactoring journey to a clean(er) React architecture.

The React Job Simulator

PS: Shared API client with fetch

While I used axios in this example there’s no requirement for it.

In fact, axios adds to your app’s bundle size while fetch is supported natively. So there’s good reason to ditch it.

fetch unfortunately doesn’t come with the same convenience though so we need a bit more code to create a sharable “instance”.

const API_BASE_URL = '/api';

export const apiClient = async (endpoint, options = {}) => {
  const config = {
    method: 'GET', // Default to GET method
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {}),
    },
  };

  return fetch(`${API_BASE_URL}${endpoint}`, config);
};
Enter fullscreen mode Exit fullscreen mode

This API client function ensures a default setup for each request while keeping the overall fetch API intact.

It might be worth creating a more abstract API client though. For example, you can also create a more Axios-like API with a JavaScript class.

class APIClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async request(url, options) {
    const response = await fetch(`${this.baseURL}${url}`, options);
    if (!response.ok) {
      const error = new Error('HTTP Error');
      error.status = response.status;
      error.response = await response.json();
      throw error;
    }
    return response.json();
  }

  get(url) {
    return this.request(url, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }

  post(url, data) {
    return this.request(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });
  }

  // You can add more methods (put, delete, etc.) here as needed
}

export const apiClient = new APIClient('/api');

Enter fullscreen mode Exit fullscreen mode

This API client can now be used like below.

import { apiClient } from './apiClient';

// To make a GET request to `/api/user/some-user-handle
apiClient.get('/user/some-user-handle')
  .then(data => console.log(data))
  .catch(error => console.error('Fetching user failed', error));

// To make a POST request
apiClient.post('/login', { username: "some-user", password: "asdf1234" })
  .then(data => console.log(data))
  .catch(error => console.error('Login failed', error));

Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
ietxaniz profile image
Iñigo Etxaniz • Edited

I also use axios currently but thinking on transitioning to use fetch. Your ApiClient class looks clean and solid. I would add headers management functionality, for example to be able to manage csrf-tokens.

class APIClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.headers = {
      'Content-Type': 'application/json' // Default header
    };
  }

  _getHeaders() {
    return this.headers;
  }

  setHeader(key, value) {
    this.headers[key] = value;
  }

 // Rest of the code using _getHeaders to set headers in each api call
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jkettmann profile image
Johannes Kettmann

Thanks, that's a good idea. The fetch API client was more meant like a rough example. So adding support for header management makes sense