The unopinionated nature of React is a two-edged sword:
This article is the fourth 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 and data transformations. 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 prevent raw response data structures from entering UI code by introducing a domain layer and DTOs. 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 an API and renders it.
// src/pages/user-profile.tsximport UserApi from "@/api/user";import { User } from "@/types";interface UserProfileProps {handle: string;}export function UserProfile({ handle }: UserProfileProps) {const [user, setUser] = useState<User>();useEffect(() => {UserApi.getUser(handle).then((user) => setUser(user));}, [handle]);return (<section><imgAsrc={user.attributes.avatar}Aalt={`${user.attributes.handle}'s avatar`}/>A<h1>@{user.attributes.handle}</h1>A<p>({user.relationships.followerIds.length} follower)</p>A<p>{user.attributes.info || "Shush! I'm a ghost."}</p></section>);}
If you’re thinking we should use a library for server state management like react-query instead of manually fetching data in a useEffect: You’re right. But for now let’s stay tool-agnostic. We’ll get to react-query in a future article.
The UI code uses an unnecessarily nested data structure for the user
object. We have
user.attributes.xyz
oruser.relationships.followerIds
multiple times. Here’s the TypeScript interface:
// src/types/index.tsexport interface User {id: string;Atype: "user";Aattributes: {handle: string;avatar: string;info?: string;};Arelationships: {followerIds: string[];};}
Similar data structures can be found all over the project even inside components deep inside the component tree.
But what’s the reason for this nesting?
Turns out the user
data is directly returned from the fetch function we created in an earlier article:
// src/api/user.tsimport { User } from "@/types";import { apiClient } from "./client";async function getUser(handle: string) {const response = await apiClient.get<{ data: User }>(`/user/${handle}`);const user = response.data.data;return user;}export default { getUser };
And inspecting the network requests we can see that this data structure is defined by the server.
The server seems to be using the popular JSON:API standard which is a great way to build APIs. But should we really use these data structures in the frontend?
The problem is: By passing the data coming from the API directly to the components we tightly couple the UI to the server. The response data doesn’t reflect the data structure of the (user) entity but is merely a data-transfer object (DTO).
DTO is a useful pattern as you can model the data according to the needs of the consumer. You can include additional data that isn’t included in the original database model like relationships.followerIds
.
But exposing the DTO to the UI components leads to unnecessary complexity by accessing fields via user.attributes.xyz
or object destructuring that bloats the code base like
const {handle,avatar,} = user.attributes;const {followerIds} = user.relationships;
Apart from the immediate extra complexity we also make the complete UI code dependent on changes of the server API.
Imagine the backend folks decide to ditch the JSON:API standard. Suddenly there are no user.attributes
anymore. But we sprinkled them all over the UI code. So just because the server API changed we now have to adjust like 90% of the frontend code as well.
We start by creating a new TypeScript interface that simplifies the User
. In this case we just flatten the API data structure.
// src/domain/index.tsexport interface User {id: string;handle: string;avatar: string;info?: string;followerIds: string[];}
Side note: Here we introduced a new folder on the root level called domain
__. The domain is a concept reflecting the core of the business used e.g. in the Clean Architecture or Domain Driven Design (DDD).
Instead of the data structures returned by the API let’s use the domain model in our component.
// src/pages/user-profile/user-info.tsximport UserApi from "@/api/user";Aimport { User } from "@/domain";interface UserProfileProps {handle: string;}export function UserProfile({ handle }: UserProfileProps) {Aconst [user, setUser] = useState<User>();useEffect(() => {UserApi.getUser(handle).then((user) => setUser(user));}, [handle]);return (<section><imgAsrc={user.avatar}Aalt={`${user.handle}'s avatar`}/>A<h1>@{user.handle}</h1>A<p>({user.followerIds.length} follower)</p>A<p>{user.info || "Shush! I'm a ghost."}</p></section>);}
Great, we got rid of the annoying user.attributes
and user.relationships
simplifying our code a bit.
But more importantly, we decoupled our UI code from the server making it more resilient against external changes.
Great improvement, but we’re not done yet. UserApi.getUser(handle)
still returns the nested data structure coming from the API.
The current fetch function in our API layer simply returns the API data.
// src/api/user.tsimport { User } from "@/types";import { apiClient } from "./client";async function getUser(handle: string) {const response = await apiClient.get<{ data: User }>(`/user/${handle}`);Aconst user = response.data.data;Areturn user;}export default { getUser };
With our components expecting the domain model we need some changes here as well.
We start by defining the model of the data transfer object (DTO) in the API layer.
// src/api/user/dto.tsexport interface UserDto {id: string;type: "user";attributes: {handle: string;avatar: string;info?: string;};relationships: {followerIds: string[];};}
From now on this data structure is only used inside the API layer and is not to be exposed to the rest of the application code.
Thus we need to transform the API response data to the domain model. A simple function will do the job.
// src/api/user/transform.tsimport { User } from "@/domain";import { UserDto } from "./dto";export function dtoToUser(dto: UserDto): User {return {id: dto.id,avatar: dto.attributes.avatar,handle: dto.attributes.handle,info: dto.attributes.info,followerIds: dto.relationships.followerIds,};}
Next, we apply this function to the response data before returning it to the component.
// src/api/user/api.tsimport { apiClient } from "../client";import { UserDto } from "./dto";import { dtoToUser } from "./transform";async function getUser(handle: string) {const response = await apiClient.get<{ data: UserDto }>(`/user/${handle}`);const userDto = response.data.data;Areturn dtoToUser(userDto);}export default { getUser };
From an architectural perspective the new code is a lot cleaner:
This way the code is more resilient and concerns are separated.
But this decoupling also comes with a cost: Boilerplate.
For each entity we need to add one domain model, at least one DTO, and one transformer function. We have more code and a lot more files in our code base.
Also the level of complexity increased. At least for developers who aren’t familiar with the code base or its architecture.
Once you get used to the architecture everything is quite clear though thanks to the conventions and consistency.
And while the boilerplate problem exists you can also automate the creation of some of these files depending on your tech stack.
For example:
You may have realized that we’re slowly adding responsibilities to the API fetch functions, like getUser
. It’s not only responsible for sending API requests but also for transforming response data.
// src/api/user/api.tsasync function getUser(handle: string) {const response = await apiClient.get<{ data: UserDto }>(`/user/${handle}`);const userDto = response.data.data;return dtoToUser(userDto);}export default { getUser };
Here’s another example from the code base:
In the next article of this series we will use the repository pattern to move some of these responsibilities away from the fetch functions.