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.
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.
Top comments (2)
for it to be properly decoupled from the UI, you'd ideally want the responses resolved outside of a components useEffect, i.e. in Redux as a memoised selector, or apollo-client or react-query, even just a custom hook as an additional abstration layer if none of the state/api/query managers/libs are neccesary.
You're completely right. I'll introduce react-query in a future step. The goal of this series of articles was to make the refactoring in tiny steps. These things tend to get complicated quickly :)