Path To A Clean(er) React Architecture - A Shared API Client
Tags: react, javascript, webdev, frontend
Published: 2024-04-12 11:24:35

---
description: Discover the foundational steps towards a clean React architecture by centralizing API requests with a shared API client. Learn how to streamline your codebase for maintainability and scalability.
---

_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](#bad-code-hardcoded-api-request-configuration-all-over-the-place)
2. [Why is this code bad?](#why-is-this-code-bad)
3. [Solution: A shared API client](#solution-a-shared-api-client)
4. [Why is this code better?](#why-is-this-code-better)
5. [Next refactoring steps: Introducing an API layer](#next-refactoring-steps-introducing-an-api-layer)
6. [PS: Shared API client with fetch](#ps-shared-api-client-with-raw-fetch-endraw-)

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

{% embed https://youtu.be/GpRYT3CQ-Y0 %}

## 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.

```typescript
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 requests use the same base path all over the place.](https://media.graphassets.com/asA5PvL5RduL7oPe1BX1)

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.

```typescript
// /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.

```typescript
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.

```javascript
// /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:

```typescript
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!

![The UI code still has a lot of knowledge about the API requests like request method, response data type, endpoint path, and parameter handling.](https://media.graphassets.com/PKm7DHQHfTszQSyqSUAO)

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

[![The React Job Simulator](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8bupb5hadajo1ok905li.png)](https://profy.dev)

## 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”.

```javascript
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.

```javascript
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.

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

```