The unopinionated nature of React is a two-edged sword:
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.
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 }>();Aconst [user, setUser] = useState<UserResponse>();Aconst [userShouts, setUserShouts] = useState<UserShoutsResponse>();const [hasError, setHasError] = useState(false);useEffect(() => {if (!handle) {return;}UserApi.getUser(handle)A.then((response) => setUser(response)).catch(() => setHasError(true));UserApi.getUserShouts(handle)A.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">A<UserInfo user={user.data} /><ShoutListAusers={[user.data]}Ashouts={userShouts.data}Aimages={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;}
We have the raw response data structures inside the component. But if you think about it, the UI code
User
object is returned as response.data
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.
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 axiosAconst user = response.data.data;Areturn user;}export interface UserShoutsResponse {data: Shout[];included: Image[];}async function getUserShouts(handle: string) {const response = await apiClient.get<UserShoutsResponse>(`/user/${handle}/shouts`);Aconst shouts = response.data.data;Aconst images = response.data.included;Areturn { 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 }>();Aconst [user, setUser] = useState<User>();Aconst [shouts, setShouts] = useState<Shout[]>();Aconst [images, setImages] = useState<Image[]>([]);const [hasError, setHasError] = useState(false);useEffect(() => {if (!handle) {return;}UserApi.getUser(handle)A.then((user) => setUser(user)).catch(() => setHasError(true));UserApi.getUserShouts(handle)A.then(({ shouts, images }) => {AsetShouts(shouts);AsetImages(images);A}).catch(() => setHasError(true));}, [handle]);...return (<div className="max-w-2xl w-full mx-auto flex flex-col p-6 gap-6">A<UserInfo user={user} />A<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
Clearly things can be more complex. So let’s look at another example.
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() {Aconst [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 />;}Aconst users = feed.included.filter((u): u is User => u.type === "user");Aconst 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;}
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 imagesAshouldn’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 theincluded
field with mixed data types looks weird? You might not have encountered the popular JSON:API standard yet.
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");Aconst shouts = response.data.data;Aconst users = response.data.included.filter(A(u): u is User => u.type === "user"A);Aconst images = response.data.included.filter(A(i): i is Image => i.type === "image"A);Areturn { 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() {Aconst [feed, setFeed] = useState<{Ashouts: Shout[];Aimages: Image[];Ausers: User[];A}>();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?
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;Alet imageId = undefined;Aif (files?.length) {Aconst formData = new FormData();AformData.append("image", files[0]);Aconst image = await MediaApi.uploadImage(formData);AimageId = image.data.id;A}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 herereturn (...);}
The chained requests are a problem for the future. Here we focus on the preparation of the input data. Particularly, the image upload.
It appears that the MediaApi
expects the image to be added to a FormData
object.
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.
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";Aasync function uploadImage(file: File) {Aconst formData = new FormData();AformData.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;Alet image;Aif (files?.length) {Aimage = await MediaApi.uploadImage(files[0]);A}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 herereturn (...);}
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.
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.