The unopinionated nature of React is a two-edged sword:
This article is the seventh 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,
This helped us in isolating our UI code from the server, make business logic independent of the UI framework, and increase testability.
But we’re not done yet.
In this article, we’ll focus on the core of our application: the domain.
Domain logic is code that operates on the domain models like a user object. That may sound abstract but we’ll see what it means in some hands-on examples. The goal is to isolate this logic from our components, move it to a specific place in the repository, and unit test it.
If you’ve ever wondered where you can place certain parts of logic if not a utility file or a custom hook this might be an interesting read.
Let’s have a look at a problematic code example. Here’s a component that renders a list of Shouts (aka Tweets or Posts).
You can find the source code before and after this refactoring. Addtionally you can find an overview of all the changes for this article here.
// src/components/shout-list/shout-list.tsximport { Shout } from "@/components/shout";import { Image } from "@/domain/media";import { Shout as IShout } from "@/domain/shout";import { User } from "@/domain/user";interface ShoutListProps {shouts: IShout[];images: Image[];users: User[];}export function ShoutList({ shouts, users, images }: ShoutListProps) {return (<ul className="flex flex-col gap-4 items-center">{shouts.map((shout) => {const author = users.find((u) => u.id === shout.authorId);const image = shout.imageId? images.find((i) => i.id === shout.imageId): undefined;return (<li key={shout.id} className="max-w-sm w-full"><Shout shout={shout} author={author} image={image} /></li>);})}</ul>);}
The problematic lines of this component find the author and image of a Shout.
const author = users.find((u) => u.id === shout.authorId);const image = shout.imageId? images.find((i) => i.id === shout.imageId): undefined;
These are operations on the user and image entities. Namely this code defines how the user and image lookup work in our application.
Additionally there’s a possibility that a Shout doesn’t have an image. Because of that we need a ternary expression. From my perspective that’s not the most beautiful or readable code.
In an earlier article, we created a domain layer holding the core models of our application like User
or Image
as TypeScript interfaces. This turns out to be a great place to put logic that operates on domain entities.
Let me show you…
Taking the example code above we can create a new domain function called getUserById
:
// src/domain/user/user.tsexport interface User {id: string;handle: string;avatar: string;info?: string;blockedUserIds: string[];followerIds: string[];}export function getUserById(users?: User[], userId?: string) {if (!userId || !users) return;return users.find((u) => u.id === userId);}
In this example, we make the function more flexible than required by making both parameters optional. From my experience, this is especially useful when you work with asynchronous data or with libraries like react-query
.
We can do the same for the image:
// src/domain/media/media.tsexport interface Image {id: string;url: string;}export function getImageById(images?: Image[], imageId?: string) {if (!imageId || !images) return;return images.find((i) => i.id === imageId);}
Now in the component the advantages become evident:
// src/components/shout-list/shout-list.tsximport { Shout } from "@/components/shout";Aimport { Image, getImageById } from "@/domain/media";import { Shout as IShout } from "@/domain/shout";Aimport { User, getUserById } from "@/domain/user";...export function ShoutList({ shouts, users, images }: ShoutListProps) {return (<ul className="flex flex-col gap-4 items-center">{shouts.map((shout) => (<li key={shout.id} className="max-w-sm w-full"><Shoutshout={shout}Aauthor={getUserById(users, shout.authorId)}Aimage={getImageById(images, shout.imageId)}/></li>))}</ul>);}
users.find(...)
functions in to ID lookups.What do I mean by “simpler to test”?
In the original code we’d have to test the logic by passing different versions of the shouts
, users
, and images
props. Here a quick reminder of the original code:
export function ShoutList({ shouts, users, images }: ShoutListProps) {return (<ul className="flex flex-col gap-4 items-center">{shouts.map((shout) => {const author = users.find((u) => u.id === shout.authorId);const image = shout.imageId? images.find((i) => i.id === shout.imageId): undefined;return (<li key={shout.id} className="max-w-sm w-full"><Shout shout={shout} author={author} image={image} /></li>);})}</ul>);}
Here specifically we’d have to provide different test cases for Shouts that contain an imageId
and that don’t. We might even want to test other edge cases like a missing author or so. On top of that we’d have to test the component with e.g. React Testing Library which creates additional overhead.
Compared to that writing unit tests for our new domain functions is very straightforward:
// src/domain/media/media.test.tsimport { describe, expect, it } from "vitest";import { getImageById } from "./media";const mockImage = {id: "1",url: "test",};describe("Media domain", () => {describe("getImageById", () => {it("should be able to get image by id", () => {const image = getImageById([mockImage], "1");expect(image).toEqual(mockImage);});it("should return undefined if image is not found", () => {const image = getImageById([{ ...mockImage, id: "2" }], "1");expect(image).toEqual(undefined);});it("should return undefined if provided images are not defined", () => {const image = getImageById(undefined, "1");expect(image).toEqual(undefined);});it("should return undefined if provided image id is not defined", () => {const image = getImageById([mockImage], undefined);expect(image).toEqual(undefined);});});});
We can test all the possible branches without any setup code as often required by React Testing Library and these tests are blazing fast.
This can provide the opportunity to remove some (more expensive) integration tests that would otherwise be required.
Let me try to solidify this approach with another example. In a previous article, we have a hook that exposes a use-case function as a way to remove business logic from a component. This function
// src/application/reply-to-shout/reply-to-shout.tsimport { useCallback } from "react";import MediaService from "@/infrastructure/media";import ShoutService from "@/infrastructure/shout";import UserService from "@/infrastructure/user";...export async function replyToShout({ recipientHandle, shoutId, message, files }: ReplyToShoutInput,{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies) {const me = await getMe();if (me.numShoutsPastDay >= 5) {return { error: ErrorMessages.TooManyShouts };}const recipient = await getUser(recipientHandle);if (!recipient) {return { error: ErrorMessages.RecipientNotFound };}if (recipient.blockedUserIds.includes(me.id)) {return { error: ErrorMessages.AuthorBlockedByRecipient };}try {let image;if (files?.length) {image = await saveImage(files[0]);}const newShout = await createShout({message,imageId: image?.id,});await createReply({shoutId,replyId: newShout.id,});return { error: undefined };} catch {return { error: ErrorMessages.UnknownError };}}export function useReplyToShout() {return useCallback((input: ReplyToShoutInput) => replyToShout(input, dependencies),[]);}
The problematic lines of code are in the data validation:
const me = await getMe();Aif (me.numShoutsPastDay >= 5) {return { error: ErrorMessages.TooManyShouts };}const recipient = await getUser(recipientHandle);if (!recipient) {return { error: ErrorMessages.RecipientNotFound };}Aif (recipient.blockedUserIds.includes(me.id)) {return { error: ErrorMessages.AuthorBlockedByRecipient };}
These are again operations on domain entities: User
and Me
(the current user).
And again we can move this logic to the domain layer:
// src/domain/me/me.tsimport { User } from "@/domain/user";export const MAX_NUM_SHOUTS_PER_DAY = 5;export interface Me extends User {numShoutsPastDay: number;}export function hasExceededShoutLimit(me: Me) {return me.numShoutsPastDay >= MAX_NUM_SHOUTS_PER_DAY;}
Now the domain is responsible for deciding whether or not a user has exceeded the shout limit. This allows us to remove implementation details such as
from the code that’s closer to the UI.
We can do the same with the check of the blockedUserIds
:
// src/domain/user/user.tsexport interface User {id: string;handle: string;avatar: string;info?: string;blockedUserIds: string[];followerIds: string[];}...export function hasBlockedUser(user?: User, userId?: string) {if (!user || !userId) return false;return user.blockedUserIds.includes(userId);}
The use-case function now reads simpler and as mentioned contains less implementation details related to the domain (e.g. how many times a user can shout in a given interval).
// src/application/reply-to-shout/reply-to-shout.tsimport { hasExceededShoutLimit } from "@/domain/me";import { hasBlockedUser } from "@/domain/user";export async function replyToShout({ recipientHandle, shoutId, message, files }: ReplyToShoutInput,{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies) {const me = await getMe();Aif (hasExceededShoutLimit(me)) {return { error: ErrorMessages.TooManyShouts };}const recipient = await getUser(recipientHandle);if (!recipient) {return { error: ErrorMessages.RecipientNotFound };}Aif (hasBlockedUser(recipient, me.id)) {return { error: ErrorMessages.AuthorBlockedByRecipient };}
Finally, we again can test this logic with simple unit tests:
// src/domain/user/user.test.tsimport { describe, expect, it } from "vitest";import { getUserById, hasBlockedUser } from "./user";const mockUser = {id: "1",handle: "test",avatar: "test",numShoutsPastDay: 0,blockedUserIds: [],followerIds: [],};describe("User domain", () => {describe("getUserById", () => { ... });describe("hasBlockedUser", () => {it("should be false if user has not blocked the user", () => {const user = { ...mockUser, blockedUserIds: ["2"] };const hasBlocked = hasBlockedUser(user, "3");expect(hasBlocked).toEqual(false);});it("should be true if user has blocked the user", () => {const user = { ...mockUser, blockedUserIds: ["2"] };const hasBlocked = hasBlockedUser(user, "2");expect(hasBlocked).toEqual(true);});it("should be false if user is not defined", () => {const hasBlocked = hasBlockedUser(undefined, "2");expect(hasBlocked).toEqual(false);});it("should be false if user id is not defined", () => {const hasBlocked = hasBlockedUser(mockUser, undefined);expect(hasBlocked).toEqual(false);});});});
Let’s quickly discuss some of the advantages and disadvantages of this approach.
users.find(({ id }) => id === userId)
requires a bit of cognitive overload to translate this into a ID lookup. Reading getUserById(users, userId)
instead is much more descriptive. That’s especially effective if you many of these lines grouped together (e.g. at the top of a component).users.find(({ id }) => id === userId)
. The code might be written in different ways with different variable names. A global search for getUserById
is straightforward though.users
and userId
parameters of the getUserById
function optional). Since we also covered these cases with unit tests we introduced more code than required leading to more maintenance effort. At the same time, this can save your butt if you e.g. suddenly encounter unexpected response data from the server.Until now we didn’t introduce any other tool than React itself. From my perspective, this was important as it can be difficult to transfer knowledge from one tech stack to another if things are mingled together.
But now it’s time to confront the reality of production React apps.
One of the most common tools used in the wild is react-query
(or another server state management library like RTK query or SWR). How do these fit into our architecture? That’s the topic of the next article.