The unopinionated nature of React is a two-edged sword:
This article is the sixth 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,
All this to isolate our UI code from the server and increase testability.
But we’re not done yet.
In this article, we’re moving closer to the components. From my experience, in many React apps, there’s no real separation between business logic and UI code. There’s no consensus about where to put logic so it often ends up in the component, a custom hook, or a utility file. This can lead to code that is hard to understand and difficult to test.
And that’s exactly what we’ll address here.
Let’s have a look at a problematic code example (you can find the source code here and the relevant changes in this PR). Here’s a component that renders a form with a submit handler attached. The submit handler
// src/components/shout/reply-dialog.tsximport MediaService from "@/infrastructure/media";import ShoutService from "@/infrastructure/shout";import UserService from "@/infrastructure/user";...const ErrorMessages = {TooManyShouts:"You have reached the maximum number of shouts per day. Please try again tomorrow.",RecipientNotFound: "The user you want to reply to does not exist.",AuthorBlockedByRecipient:"You can't reply to this user. They have blocked you.",UnknownError: "An unknown error occurred. Please try again later.",} as const;export function ReplyDialog({recipientHandle,children,shoutId,}: ReplyDialogProps) {const [open, setOpen] = useState(false);const [isLoading, setIsLoading] = useState(true);const [replyError, setReplyError] = useState<string>();...async function handleSubmit(event: React.FormEvent<ReplyForm>) {event.preventDefault();setIsLoading(true);Aconst me = await UserService.getMe();Aif (me.numShoutsPastDay >= 5) {Areturn setReplyError(ErrorMessages.TooManyShouts);A}AAconst recipient = await UserService.getUser(recipientHandle);Aif (!recipient) {Areturn setReplyError(ErrorMessages.RecipientNotFound);A}Aif (recipient.blockedUserIds.includes(me.id)) {Areturn setReplyError(ErrorMessages.AuthorBlockedByRecipient);A}try {const message = event.currentTarget.elements.message.value;const files = event.currentTarget.elements.image.files;Alet image;Aif (files?.length) {Aimage = await MediaService.saveImage(files[0]);A}AAconst newShout = await ShoutService.createShout({Amessage,AimageId: image?.id,A});AAawait ShoutService.createReply({AshoutId,AreplyId: newShout.id,A});setOpen(false);} catch (error) {setReplyError(ErrorMessages.UnknownError);} finally {setIsLoading(false);}}return (<Dialog open={open} onOpenChange={setOpen}>{/* more JSX here */}</Dialog>);}
Fairly straightforward, right? There’s quite a bit of code inside the submit handler but what can we do!?
The thing is that the component has quite a lot of responsibilities. Some of these fall well into the concern of a UI component like
But much of the code in the submit handler has nothing to do with UI.
Most of the code is data validation and orchestrating calls to services / Rest APIs. That’s business logic and can be isolated from the UI framework.
Imagine we wanted to migrate from React to any other future UI library. We’d have to untangle this mixture of UI and business logic.
Ok, that’s an unlikely event far in the future.
More urgent is this question: How would you test this component?
Do I hear “integration tests”? I know, integration tests have become very popular in the React community. Especially thanks to React Testing Library and its premise to test an application from the user’s perspective.
But in this case, we would need to jump through a few hoops to test all the different branches. Each return statement represents one branch. Plus the catch block at the end.
So we have test scenarios like:
Yes, we can test this with integration tests. We could mock the requests with e.g. Mock Service Worker. Then we’d render the app, fill in the form inputs, click the submit button, and check that the UI shows the correct result.
But this is relatively complex as we need to set up different mock requests. Additionally, these tests would be relatively slow as we’d have to walk through the same UI interactions for each test.
Let’s have a look at an alternative approach.
Similar to the previous article, the idea is to move the business logic unrelated to the UI to a separate function. We then create a custom hook to facilitate dependency injection. This helps us simplify our tests: No need for an integration test for each branch. Instead we can unit test the business logic.
We start by extracting all the business logic into a new function in a new file.
// src/application/reply-to-shout.tsimport MediaService from "@/infrastructure/media";import ShoutService from "@/infrastructure/shout";import UserService from "@/infrastructure/user";interface ReplyToShoutInput {recipientHandle: string;shoutId: string;message: string;files?: File[] | null;}const ErrorMessages = {TooManyShouts:"You have reached the maximum number of shouts per day. Please try again tomorrow.",RecipientNotFound: "The user you want to reply to does not exist.",AuthorBlockedByRecipient:"You can't reply to this user. They have blocked you.",UnknownError: "An unknown error occurred. Please try again later.",} as const;export async function replyToShout({recipientHandle,shoutId,message,files,}: ReplyToShoutInput) {const me = await UserService.getMe();if (me.numShoutsPastDay >= 5) {return { error: ErrorMessages.TooManyShouts };}const recipient = await UserService.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 MediaService.saveImage(files[0]);}const newShout = await ShoutService.createShout({message,imageId: image?.id,});await ShoutService.createReply({shoutId,replyId: newShout.id,});return { error: undefined };} catch {return { error: ErrorMessages.UnknownError };}}
Now this function contains no code related to the UI or React (except for maybe the error messages that could be replaced by some kind of error codes). Even if we migrated to another UI framework we could keep this code without any changes.
A quick side note: In this series of articles we roughly follow the Clean Architecture. In this context, the business logic we extracted is referred to as “application logic” or “use cases” belonging to the “application layer”.
To keep things clear, we create the file in a new global application
folder. Later we’ll restructure this project to use a feature-driven folder structure. But for now it’s easier to separate the different layers in their respective folders.
Anyway, let’s continue with the refactoring.
With all this logic isolated in a separate function, the component’s submit handler is much slimmer now.
// src/components/shout/reply-dialog.tsxasync function handleSubmit(event: React.FormEvent<ReplyForm>) {event.preventDefault();setIsLoading(true);const message = event.currentTarget.elements.message.value;const files = Array.from(event.currentTarget.elements.image.files ?? []);+const result = await replyToShout({+ recipientHandle,+ message,+ files,+ shoutId,+});if (result.error) {setReplyError(result.error);}setOpen(false);setIsLoading(false);}
Alright, we covered our potential future “let’s migrate to another UI framework” scenario. But testability-wise not much changed. So let’s tackle that.
The replyToShout
function uses a couple of services to send API requests.
// src/application/reply-to-shout.tsimport MediaService from "@/infrastructure/media";import ShoutService from "@/infrastructure/shout";import UserService from "@/infrastructure/user";export async function replyToShout({ ... }) {const me = await UserService.getMe();const recipient = await UserService.getUser(recipientHandle);...let image;if (files?.length) {image = await MediaService.saveImage(files[0]);}const newShout = await ShoutService.createShout({ ... });await ShoutService.createReply({ ... });}
This means it “has dependencies” on these services. That makes testing the replyToShout
function difficult. We’d have to mock the service modules or the REST API used in the services.
Dependency injection to the rescue.
Dependency injection is a common pattern used to increase testability. Sounds complicated but actually it’s quite simple:
import { useCallback } from "react";import MediaService from "@/infrastructure/media";import ShoutService from "@/infrastructure/shout";import UserService from "@/infrastructure/user";...// We create the dependencies as a separate object using the services.// This way it's already typed and we don't need another TS interface.Aconst dependencies = {AgetMe: UserService.getMe,AgetUser: UserService.getUser,AsaveImage: MediaService.saveImage,AcreateShout: ShoutService.createShout,AcreateReply: ShoutService.createReply,A};// The replyToShout function accepts the dependencies as second parameter.// Now the code that calls this function decides what to provide as e.g. getMe.// This is called inversion of control and helps with unit testing.export async function replyToShout({ recipientHandle, shoutId, message, files }: ReplyToShoutInput,A{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies) {Aconst me = await getMe();if (me.numShoutsPastDay >= 5) {return { error: ErrorMessages.TooManyShouts };}Aconst 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) {Aimage = await saveImage(files[0]);}Aconst newShout = await createShout({message,imageId: image?.id,});Aawait createReply({shoutId,replyId: newShout.id,});return { error: undefined };} catch {return { error: ErrorMessages.UnknownError };}}// This hook is just a mechanism to inject the dependencies. A component can// use this hook without having to care about providing the dependencies.Aexport function useReplyToShout() {Areturn useCallback(A(input: ReplyToShoutInput) => replyToShout(input, dependencies),A[]A);A}
Note that this file became a bit “dirty”. If we followed the Clean Architecture by the book the application layer shouldn’t contain any references to the UI framework. This file contains code that is specific to the UI framework though (here by exporting a hook and applying useCallback
). So this file isn’t completely clean. But I think we can be pragmatic here.
Anyway, as we’ll see in a bit it’s now straightforward to test the replyToShout
function.
If you think this looks more and more like a typical react-query hook, I agree. In one of the next articles, we’ll introduce react-query into the picture. But for now let’s stay tool-agnostic.
Since we use dependency injection we can now simply create mock functions and pass those to the replyToShout
function. We then assert that the correct error is returned or that the service functions received the correct values.
import { beforeEach, describe, expect, it, vitest } from "vitest";import { createMockFile } from "@/test/create-mock-file";import { ErrorMessages, replyToShout } from "./reply-to-shout";const recipientHandle = "recipient-handle";const shoutId = "shout-id";const message = "Hello, world!";const files = [createMockFile("image.png")];const imageId = "image-id";const newShoutId = "new-shout-id";// The mock data and service functions below could be moved to centralized// factory functions. This would simplify managing test data on a larger scale.const mockMe = {id: "user-1",handle: "me",avatar: "user-1.png",numShoutsPastDay: 0,blockedUserIds: [],followerIds: [],};const mockRecipient = {id: "user-2",handle: recipientHandle,avatar: "user-2.png",numShoutsPastDay: 0,blockedUserIds: [],followerIds: [],};const mockGetMe = vitest.fn().mockResolvedValue(mockMe);const mockGetUser = vitest.fn().mockResolvedValue(mockRecipient);const mockSaveImage = vitest.fn().mockResolvedValue({ id: imageId });const mockCreateShout = vitest.fn().mockResolvedValue({ id: newShoutId });const mockCreateReply = vitest.fn();const mockDependencies = {getMe: mockGetMe,getUser: mockGetUser,saveImage: mockSaveImage,createShout: mockCreateShout,createReply: mockCreateReply,};describe("replyToShout", () => {beforeEach(() => {Object.values(mockDependencies).forEach((mock) => mock.mockClear());});it("should return an error if the user has made too many shouts", async () => {mockGetMe.mockResolvedValueOnce({ ...mockMe, numShoutsPastDay: 5 });const result = await replyToShout({ recipientHandle, shoutId, message, files },mockDependencies);expect(result).toEqual({ error: ErrorMessages.TooManyShouts });});it("should return an error if the recipient does not exist", async () => {mockGetUser.mockResolvedValueOnce(undefined);const result = await replyToShout({ recipientHandle, shoutId, message, files },mockDependencies);expect(result).toEqual({ error: ErrorMessages.RecipientNotFound });});it("should return an error if the recipient has blocked the author", async () => {mockGetUser.mockResolvedValueOnce({...mockRecipient,blockedUserIds: [mockMe.id],});const result = await replyToShout({ recipientHandle, shoutId, message, files },mockDependencies);expect(result).toEqual({ error: ErrorMessages.AuthorBlockedByRecipient });});it("should create a new shout with an image and reply to it", async () => {await replyToShout({ recipientHandle, shoutId, message, files },mockDependencies);expect(mockSaveImage).toHaveBeenCalledWith(files[0]);expect(mockCreateShout).toHaveBeenCalledWith({message,imageId,});expect(mockCreateReply).toHaveBeenCalledWith({shoutId,replyId: newShoutId,});});it("should create a new shout without an image and reply to it", async () => {await replyToShout({ recipientHandle, shoutId, message, files: [] },mockDependencies);expect(mockSaveImage).not.toHaveBeenCalled();expect(mockCreateShout).toHaveBeenCalledWith({message,imageId: undefined,});expect(mockCreateReply).toHaveBeenCalledWith({shoutId,replyId: newShoutId,});});});
Now we covered all the edge cases of the replyToShout
use case with blazing fast unit tests. No need to mock half a dozen different user responses and run through all UI interactions for each of those branches.
With a bit more effort we could even use TypeScript as another layer of safety net. But that would go too far for this time. We’ll cover it in a future article.
Still, let me explain quickly:
Currently we can pass anything we want as mock dependencies. But that can cause a mismatch between the parameters and return types of the real service functions and the mocks. And that again can lead to situations where all our unit tests pass but the application as a whole breaks.
This scenario is usually covered by integration or end-to-end tests. Since these test the features integrated into the larger system a mismatch between mocks and actual implementation quickly becomes evident.
Ok, that’s it for this refactoring. Let me quickly wrap up with a summary of the pros and cons.
We discussed the advantages already. Here a quick summary:
But what about the disadvantages?
To be honest, I don’t see many. But here are a few thoughts:
Overall the advantages in this case outweigh any potential disadvantages from my perspective.
This time we talked a lot about business or application logic. Next time, we’ll take a step closer to the core and focus on domain logic.
In fact, we’ve seen this logic mixed with out business logic in the use case function replyToShout
.
The next step is to move this kind of logic to the domain layer.