Path To A Clean(er) React Architecture (Part 6)Business Logic Separation

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 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.

Problematic code example: Business logic mixed with UI logic

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

  • updates some UI state
  • checks some pre-conditions regarding the users involved
  • saves an image and creates a reply
  • and finally updates the UI state again.
// src/components/shout/reply-dialog.tsx
import 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);
const me = await UserService.getMe();
if (me.numShoutsPastDay >= 5) {
return setReplyError(ErrorMessages.TooManyShouts);
}
const recipient = await UserService.getUser(recipientHandle);
if (!recipient) {
return setReplyError(ErrorMessages.RecipientNotFound);
}
if (recipient.blockedUserIds.includes(me.id)) {
return setReplyError(ErrorMessages.AuthorBlockedByRecipient);
}
try {
const message = event.currentTarget.elements.message.value;
const files = event.currentTarget.elements.image.files;
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,
});
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 problem: Mixed concerns and low testability

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

  • rendering the UI (doh),
  • handling the submit event,
  • or updating the loading or error state.

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:

  • A user who shouted more than 5 times and one that didn’t.
  • A recipient that doesn’t exist and one that has blocked the current user.
  • Failing API requests in the services that trigger the catch block.
  • And finally the happy path.

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.

The solution: Extracting business logic and using dependency injection

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.

Step 1: Extracting business logic to a use-case

We start by extracting all the business logic into a new function in a new file.

// src/application/reply-to-shout.ts
import 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.tsx
async 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.

Step 2: Custom hook for dependency injection

The replyToShout function uses a couple of services to send API requests.

// src/application/reply-to-shout.ts
import 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:

  1. We adjust the function to accept a new “dependencies” parameter with the relevant service functions.
  2. Then we create a custom hook that “injects” these dependencies.
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.
const dependencies = {
getMe: UserService.getMe,
getUser: UserService.getUser,
saveImage: MediaService.saveImage,
createShout: ShoutService.createShout,
createReply: ShoutService.createReply,
};
// 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,
{ 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 };
}
}
// This hook is just a mechanism to inject the dependencies. A component can
// use this hook without having to care about providing the dependencies.
export function useReplyToShout() {
return useCallback(
(input: ReplyToShoutInput) => replyToShout(input, dependencies),
[]
);
}

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.

Step 3: Unit testing the business logic

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.

Advantages and disadvantages of business logic in use cases

We discussed the advantages already. Here a quick summary:

  • Isolating business logic from the UI makes it independent of the UI framework.
  • Using dependency injection makes it easy to test the logic.
  • Removing logic from the components let’s them focus on their responsibility: the UI.
  • Plus you finally have a place to put your business logic apart from util functions and custom hooks.

But what about the disadvantages?

To be honest, I don’t see many. But here are a few thoughts:

  • There’s a bit of overhead because we created a custom hook for dependency injection.
  • This kind of separation is only valuable if you in fact have logic. You don’t need it for a simple API request.
  • It might not be that simple to distinguish between different types of logic. At least it took me a while to become better at identifying logic that’s worth being extracted.
  • We introduced an architecture that is not very common in React apps so new developers have to get used to it.

Overall the advantages in this case outweigh any potential disadvantages from my perspective.

Next refactoring steps

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.

Improve your health as a software developer
After years working in front of a screen, my health was at an all-time low. I gained weight, my back hurt, and I was constantly tired. I knew I had to make a change. That's why I started Office Walker. I want to help you improve your health and productivity as a software developer.