The unopinionated nature of React is a two-edged sword:
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.
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(() => {axiosA.get<UserResponse>(`/api/user/${handle}`).then((response) => setUser(response.data)).catch(() => setHasError(true));axiosA.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} /><ShoutListusers={[user.data]}shouts={userShouts.data}images={userShouts.included}/></div>);}
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?
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.tsimport 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(() => {AapiClient.get<UserResponse>(`/user/${handle}`).then((response) => setUser(response.data)).catch(() => setHasError(true));AapiClient.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…
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.tsimport axios from "axios";export const apiClient = axios.create({AbaseURL: "/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
/api
These are implementation details that the UI shouldn’t care about.
While these are important changes we’re far from done yet.
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!
We’ll continue this refactoring journey to a clean(er) React architecture in the next article.
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-handleapiClient.get('/user/some-user-handle').then(data => console.log(data)).catch(error => console.error('Fetching user failed', error));// To make a POST requestapiClient.post('/login', { username: "some-user", password: "asdf1234" }).then(data => console.log(data)).catch(error => console.error('Login failed', error));