Path To A Clean(er) React ArchitectureAPI Layer & Data Transformations

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 third 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 created the initial API layer and extracted fetch functions that can be used in the components. This way we already removed a lot of implementation details related to API requests from the UI code.

But we’re not done yet.

In this article, we identify data transformations in the component that can be moved to the API layer. Another step towards a cleaner architecture.

Bad Code - Example 1: API responses inside UI code

Let’s have a look at a problematic code example. Here is a user profile component that fetches data from two different endpoints.

Can you identify the problem?

import { useEffect, useState } from "react";
import { Navigate, useParams } from "react-router";
import UserApi from "@/api/user";
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(() => {
if (!handle) {
return;
}
UserApi.getUser(handle)
.then((response) => setUser(response))
.catch(() => setHasError(true));
UserApi.getUserShouts(handle)
.then((response) => setUserShouts(response))
.catch(() => setHasError(true));
}, [handle]);
if (!handle) {
return <Navigate to="/" />;
}
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>
);
}

For completion, these are the fetch functions used in the component: UserAPI.getUser() and UserAPI.getUserShouts()

export interface UserResponse {
data: User;
}
async function getUser(handle: string) {
const response = await apiClient.get<UserResponse>(`/user/${handle}`);
return response.data;
}
export interface UserShoutsResponse {
data: Shout[];
included: Image[];
}
async function getUserShouts(handle: string) {
const response = await apiClient.get<UserShoutsResponse>(
`/user/${handle}/shouts`
);
return response.data;
}

What is the problem?

We have the raw response data structures inside the component. But if you think about it, the UI code

  • shouldn’t care that the User object is returned as response.data
  • shouldn’t need to know that the shouts are in response.data and the images inside response.included.

Imagine we pass these response data structures to a dozen or more components in a larger project. Now imagine that the API changes the data structure. We’d have to adjust all of those components one by one.

Additionally, the code would be more readable if we wouldn’t have to deal with all those data and included fields anymore. Name it user, shout, or image instead.

To summarize: All this knowledge can be hidden inside the API layer as this is where we deal with API responses and the fetch functions should return the final User, Shout, or Image data structures.

Solution: Use data transformations in the API layer

The goal is to remove all code related to API response data structures from the component.

The solution is to use data transformations in the fetch functions:

export interface UserResponse {
data: User;
}
async function getUser(handle: string) {
const response = await apiClient.get<{ data: User }>(`/user/${handle}`);
// first `.data` comes from axios
const user = response.data.data;
return user;
}
export interface UserShoutsResponse {
data: Shout[];
included: Image[];
}
async function getUserShouts(handle: string) {
const response = await apiClient.get<UserShoutsResponse>(
`/user/${handle}/shouts`
);
const shouts = response.data.data;
const images = response.data.included;
return { shouts, images };
}

Now the fetch functions return the actual data structures like User, Shout, or Image.

This allows us to remove the response data structures from the component:

export function UserProfile() {
const { handle } = useParams<{ handle: string }>();
const [user, setUser] = useState<User>();
const [shouts, setShouts] = useState<Shout[]>();
const [images, setImages] = useState<Image[]>([]);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!handle) {
return;
}
UserApi.getUser(handle)
.then((user) => setUser(user))
.catch(() => setHasError(true));
UserApi.getUserShouts(handle)
.then(({ shouts, images }) => {
setShouts(shouts);
setImages(images);
})
.catch(() => setHasError(true));
}, [handle]);
...
return (
<div className="max-w-2xl w-full mx-auto flex flex-col p-6 gap-6">
<UserInfo user={user} />
<ShoutList users={[user]} shouts={shouts} images={images} />
</div>
);
}

As you can see, all the code related to responses has vanished from the component. Not to forget that we got rid of the nasty data and included fields.

At the same time, the only changes so far are

  • we returned some nested fields instead of the complete response
  • we renamed some fields.

Clearly things can be more complex. So let’s look at another example.

Bad Code - Example 2: Data transformations in UI code

Here’s another component of the example project: it’s a feed that shows the newest “Shouts” (aka Tweets) to a user.

import FeedApi from "@/api/feed";
import { LoadingView } from "@/components/loading";
import { ShoutList } from "@/components/shout-list";
import { FeedResponse, Image, User } from "@/types";
export function Feed() {
const [feed, setFeed] = useState<FeedResponse>();
const [hasError, setHasError] = useState(false);
useEffect(() => {
FeedApi.getFeed()
.then((feed) => setFeed(feed))
.catch(() => setHasError(true));
}, []);
if (hasError) {
return <div>An error occurred</div>;
}
if (!feed) {
return <LoadingView />;
}
const users = feed.included.filter((u): u is User => u.type === "user");
const images = feed.included.filter((i): i is Image => i.type === "image");
return (
<div className="w-full max-w-2xl mx-auto flex flex-col justify-center p-6 gap-6">
<ShoutList shouts={feed.data} users={users} images={images} />
</div>
);
}

We can again see that the response is stored in the component. And as before the fetch function directly returns the API response data structure.

async function getFeed() {
const response = await apiClient.get<FeedResponse>("/feed");
return response.data;
}

What is the problem?

Ok, this looks pretty much like the same problem. Apart from these two lines of code:

const users = feed.included.filter((u): u is User => u.type === "user");
const images = feed.included.filter((i): i is Image => i.type === "image");

Apparently, we again have to deal with an included field. But this time, that array not only contains images but also users. And the above two lines are data transformation logic that separate objects of type user and image.

Meaning we have data transformation logic inside the component.

But the component

shouldn’t care that the `included` field in the feed response contains users and images
shouldn’t need to know that it can distinguish the users and images by their `type` field.

Note: You think the response data structure with the data field and the included field with mixed data types looks weird? You might not have encountered the popular JSON:API standard yet.

Solution: Move data transformation logic to the fetch function

Again, the goal is to remove this logic from the component.

The solution is simple: we move the two lines to the fetch function.

import { FeedResponse, Image, User } from "@/types";
import { apiClient } from "./client";
async function getFeed() {
const response = await apiClient.get<FeedResponse>("/feed");
const shouts = response.data.data;
const users = response.data.included.filter(
(u): u is User => u.type === "user"
);
const images = response.data.included.filter(
(i): i is Image => i.type === "image"
);
return { shouts, users, images };
}
export default { getFeed };

Now the fetch function returns the actual data structures User, Shout, and Image and doesn’t expose API responses to the UI code anymore.

The component becomes a lot simpler:

import { useEffect, useState } from "react";
import FeedApi from "@/api/feed";
import { LoadingView } from "@/components/loading";
import { ShoutList } from "@/components/shout-list";
import { Image, Shout, User } from "@/types";
export function Feed() {
const [feed, setFeed] = useState<{
shouts: Shout[];
images: Image[];
users: User[];
}>();
const [hasError, setHasError] = useState(false);
useEffect(() => {
FeedApi.getFeed()
.then((feed) => setFeed(feed))
.catch(() => setHasError(true));
}, []);
if (hasError) {
return <div>An error occurred</div>;
}
if (!feed) {
return <LoadingView />;
}
return (
<div className="w-full max-w-2xl mx-auto flex flex-col justify-center p-6 gap-6">
<ShoutList shouts={feed.shouts} users={feed.users} images={feed.images} />
</div>
);
}

Now the component directly gets the users, shouts, and images. It has no knowledge of the API responses anymore.

Great, our code becomes more streamlined. But so far we only focused on response data. What about input data like request bodies?

Bad Code - Example 3: Transformation of input data

Here’s yet another component: A dialog that allows the user to reply to another user’s shout (aka tweet).

import MediaApi from "@/api/media";
import ShoutApi from "@/api/shout";
import UserApi from "@/api/user";
...
export function ReplyDialog({ children, shoutId }: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [hasError, setHasError] = useState(false);
...
async function handleSubmit(event: React.FormEvent<ReplyForm>) {
event.preventDefault();
setIsLoading(true);
try {
const message = event.currentTarget.elements.message.value;
const files = event.currentTarget.elements.image.files;
let imageId = undefined;
if (files?.length) {
const formData = new FormData();
formData.append("image", files[0]);
const image = await MediaApi.uploadImage(formData);
imageId = image.data.id;
}
const newShout = await ShoutApi.createShout({
message,
imageId,
});
await ShoutApi.createReply({
shoutId,
replyId: newShout.data.id,
});
setOpen(false);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}
// a form using the handleSubmit function is rendered here
return (
...
);
}

The chained requests are a problem for the future. Here we focus on the preparation of the input data. Particularly, the image upload.

What is the problem?

It appears that the MediaApi expects the image to be added to a FormData object.

  • But is this something the component should be concerned with?
  • Why should it matter to the UI code whether the image is uploaded using form data or any other mechanism?

Let’s imagine we wanted to switch to another cloud storage like AWS S3? We’d most likely have to adjust the component code as well.

And since other places like the user profile are likely to upload images as well we’d have to adjust multiple parts of the codebase just because the underlying image storage changed.

We can do better.

Solution: Move transformation of input data to API layer

Similar to the previous examples, let’s move the data transformation to the API layer. Here’s the adjusted MediaApi.uploadImage() function:

import { Image } from "@/types";
import { apiClient } from "./client";
async function uploadImage(file: File) {
const formData = new FormData();
formData.append("image", file);
const response = await apiClient.post<{ data: Image }>("/image", formData);
const image = response.data.data;
return image;
}
export default { uploadImage };

The fetch function in the API layer now expects a File as input. The exact upload mechanism is hidden from the component.

This simplifies the component code quite a bit:

export function ReplyDialog({ children, shoutId }: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [hasError, setHasError] = useState(false);
...
async function handleSubmit(event: React.FormEvent<ReplyForm>) {
event.preventDefault();
setIsLoading(true);
try {
const message = event.currentTarget.elements.message.value;
const files = event.currentTarget.elements.image.files;
let image;
if (files?.length) {
image = await MediaApi.uploadImage(files[0]);
}
const newShout = await ShoutApi.createShout({
message,
imageId,
});
await ShoutApi.createReply({
shoutId,
replyId: newShout.data.id,
});
setOpen(false);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}
// a form using the handleSubmit function is rendered here
return (
...
);
}

The component is still responsible getting the files from the event target and passing only the first one files[0] to the media API. The rest of the heavy lifting is handled by the API.

Next refactoring steps

We took another step closer towards a cleaner React architecture. Data transformations are now handled by the API layer.

But there’s still room for improvement. For example, the fetch functions still pass the data structures User, Shout, or Image as they are returned from the API.

And look at this example. Ain’t pretty, right?

{
"id": "shout-1",
"type": "shout",
"createdAt": 1713455132695,
"attributes": {
"authorId": "user-1",
"text": "The world sucks!!!!",
"likes": 5,
"reshouts": 0
},
"relationships": {
"replies": [
"shout-3"
]
}
}

In the next article, we will introduce a domain layer that will help us get rid of this complexity.