Path To A Clean(er) React Architecture API Layer & Fetch Functions

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 second part 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.

Previously, we extracted an API client to share common configuration like the API base URL between all requests in the application.

In this article, we want to focus on separating API related code from the UI components.

Bad Code: Mixed API and UI code

Let’s have a look at the bad code example. Here’s the result of the first refactoring step from the previous article: a component that fetches data from two API endpoints and renders the data.

import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { apiClient } from "@/api/client";
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(() => {
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]);
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?

It’s simple: API requests and UI code are mixed. A bit of API code here, a bit of UI there.

But in fact, for the UI it shouldn’t matter how it get’s the data:

  • It shouldn’t care whether a GET, a POST, or a PATCH request is sent.
  • It shouldn’t care what the exact path of the endpoint is.
  • It shouldn’t care how the request parameters are passed to the API.
  • It shouldn’t even really care whether it connects to a REST API or a websocket.

All of this should be an implementation detail from the perspective of the UI.

Solution: Extract functions that connect to the API

Extracting functions that connect to the API to a separate place will greatly help decouple API related code from UI code.

These functions will hide the implementation details like

  • the request method
  • the endpoint path or
  • the data types.

In order to have a clear separation we will locate these functions in a global API folder.

// src/api/user.ts
import { User, UserShoutsResponse } from "@/types";
import { apiClient } from "./client";
async function getUser(handle: string) {
const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
return response.data;
}
async function getUserShouts(handle: string) {
const response = await apiClient.get<UserShoutsResponse>(
`/user/${handle}/shouts`
);
return response.data;
}
export default { getUser, getUserShouts };

Now we use these fetch functions inside the component.

import UserApi from "@/api/user";
...
export function UserProfile() {
const { handle } = useParams<{ handle: string }>();
const [user, setUser] = useState<User>();
const [userShouts, setUserShouts] = useState<UserShoutsResponse>();
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!handle) {
return;
}
UserApi.getUser(handle)
.then((response) => setUser(response.data))
.catch(() => setHasError(true));
UserApi.getUserShouts(handle)
.then((response) => setUserShouts(response))
.catch(() => setHasError(true));
}, [handle]);
...

Why is this code better? Decoupling of UI and API code

You’re right. The code changes aren’t huge. We basically moved a bit of code around. The result might initially look like somewhat cleaner component code.

// before
apiClient.get<UserResponse>(`/user/${handle}`)
// after
UserApi.getUser(handle)

But in fact, we started to effectively decouple the UI code from API related functionality.

I’m not getting tired of repeating myself: now the component doesn’t know a lot of API related details like

  • the request method GET
  • the definition of the data type UserResponse
  • the endpoint path /user/some-handle
  • or that the handle is passed to the API as URL parameter.

Instead it calls a simple function UserApi.getUser(handle) that returns a typed result wrapped in a promise.

Additionally, we can reuse these fetch functions in multiple components or for different rendering approaches like client-side or server-side rendering.

Next refactoring steps

Yes, we took a big step forward separating our UI code from the API. We introduced an API layer and removed many implementation details from the UI component.

But we still have considerable coupling between the API layer and our components. For example, we transform the response data inside the components:

Now, this might not be the most impressive example. So let’s look at another one from the same code base:

Here we can see how the response of the feed has an included field that contains users and images.

Not pretty, but sometimes we have to deal with such APIs.

But why should the component know?

Anyway, we’ll deal with that in the next article.

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.