Path To A Clean(er) React ArchitectureA Shared API Client

Johannes KettmannPublished on 

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.

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>
);
}

Why is this code bad?

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

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",
});

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]);
...
}

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",
});

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}`)

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!

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

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

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);
};

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');

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));