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 eigth 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
- added data transformations
- separated domain entities and DTOs
- introduced infrastructure services using dependency injection and
- separated business logic and domain logic from the components.
This helped us in isolating our UI code from the server, make business logic independent of the UI framework, and increase testability.
But we didn’t introduce one of the most important tools in production react apps yet: react-query or another server state management library.
That’s the topic of this article.
Table Of Contents
- Problematic code example 1: Manually managing server data
- Problematic code example #2: Business logic and react-query
- Next Refactoring Steps
Before we start a quick side note: I won’t go through the setup of react-query nor will I explain its features in detail. I assume you’re familiar with that. If not you can use the docs or one of the many tutorials out there.
Also if you haven’t read the other articles of this series I recommend doing so before you continue.
Problematic code example 1: Manually managing server data
Let’s have a look at a problematic code example. Here’s a component that fetches the currently signed-in user and renders a form in a dialog that allows the user to reply to a message (aka shout).
You can find the complete source code including all changes here.
import { useEffect, useState } from "react";
import { isAuthenticated as isUserAuthenticated } from "@/domain/me";
import UserService from "@/infrastructure/user";
...
export function ReplyDialog({
recipientHandle,
children,
shoutId,
}: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [hasError, setHasError] = useState(false);
...
useEffect(() => {
UserService.getMe()
.then(isUserAuthenticated)
.then(setIsAuthenticated)
.catch(() => setHasError(true))
.finally(() => setIsLoading(false));
}, []);
if (hasError || !isAuthenticated) {
return <LoginDialog>{children}</LoginDialog>;
}
async function handleSubmit(event: React.FormEvent<ReplyForm>) {
// we'll look at this code later
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* the rest of the component */}
</Dialog>
);
}
The problem: Boilerplate & state management
If you ever used react-query you probably know the problem.
- We manually manage the loading and error state which leads to boilerplate code.
- At the same time, we don’t even cache the API response which will lead to duplicate API requests.
These are just two things that react-query can help us with.
The solution: Creating a react-query hook
Here’s the query hook that we can replace the useEffect
with.
import { useQuery } from "@tanstack/react-query";
import UserService from "@/infrastructure/user";
export function getQueryKey() {
return ["me"];
}
export function useGetMe() {
return useQuery({
queryKey: getQueryKey(),
queryFn: () => UserService.getMe(),
});
}
The query function calls the UserService
(created in an earlier article) that returns the transformed API responses instead of the DTOs. Anyway, that doesn’t matter much here.
Now, we can simply use the new query hook instead of the useEffect
in the component.
import { useState } from "react";
import { useGetMe } from "@/application/queries/get-me";
import { useReplyToShout } from "@/application/reply-to-shout";
import { isAuthenticated } from "@/domain/me";
...
export function ReplyDialog({
recipientHandle,
children,
shoutId,
}: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [replyError, setReplyError] = useState<string>();
const replyToShout = useReplyToShout();
const me = useGetMe();
if (me.isError || !isAuthenticated(me.data)) {
return <LoginDialog>{children}</LoginDialog>;
}
async function handleSubmit(event: React.FormEvent<ReplyForm>) {
// we'll look at this code in a bit
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* the rest of the component */}
</Dialog>
);
}
We not only got rid of a lot of boilerplate code like the error and loading state handling. We also get response caching, retries, and a lot more features out of the box.
To summarize, we use the query hook as sort of proxy between the component and the service layer.
Ok, this was a pretty simple example. But what about something more complex?
Problematic code example #2: Business logic and react-query
In the previous code examples we didn’t look at the submit handler. Here it is:
import { useReplyToShout } from "@/application/reply-to-shout";
...
export function ReplyDialog({
recipientHandle,
children,
shoutId,
}: ReplyDialogProps) {
...
const replyToShout = useReplyToShout();
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);
} else {
setOpen(false);
}
setIsLoading(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* the rest of the component */}
</Dialog>
);
}
In a previous article, we extracted a bunch of business logic from the submit handler into the useReplyToShout
function highlighted above.
Currently, the useReplyToShout
hook provides a couple of services functions to the replyToShout
function by dependency injection.
import { useCallback } from "react";
import { hasExceededShoutLimit } from "@/domain/me";
import { hasBlockedUser } from "@/domain/user";
import MediaService from "@/infrastructure/media";
import ShoutService from "@/infrastructure/shout";
import UserService from "@/infrastructure/user";
...
const dependencies = {
getMe: UserService.getMe,
getUser: UserService.getUser,
saveImage: MediaService.saveImage,
createShout: ShoutService.createShout,
createReply: ShoutService.createReply,
};
export async function replyToShout(
{ recipientHandle, shoutId, message, files }: ReplyToShoutInput,
{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies
) {
const me = await getMe();
if (hasExceededShoutLimit(me)) {
return { error: ErrorMessages.TooManyShouts };
}
const recipient = await getUser(recipientHandle);
if (!recipient) {
return { error: ErrorMessages.RecipientNotFound };
}
if (hasBlockedUser(recipient, 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 problem: Duplicate requests
The replyToShout
function sends multiple API requests. Among them calls to UserService.getMe(...)
and UserService.getUser(...)
. But this data has already been fetched in other parts of the app and thus exists in the react-query cache.
Additionally, we again have to manage the loading state manually.
The solution: Using react-query data and mutations
In the previous example we already introduced a query hook useGetMe
. Now let’s add another one to get a user based on their handle.
import { useQuery } from "@tanstack/react-query";
import UserService from "@/infrastructure/user";
interface GetUserInput {
handle?: string;
}
export function getQueryKey(handle?: string) {
return ["user", handle];
}
export function useGetUser({ handle }: GetUserInput) {
return useQuery({
queryKey: getQueryKey(handle),
queryFn: () => UserService.getUser(handle),
});
}
Then we create the required mutation hooks. Here an example hook that creates a shout.
import { useMutation } from "@tanstack/react-query";
import ShoutService from "@/infrastructure/shout";
interface CreateShoutInput {
message: string;
imageId?: string;
}
export function useCreateShout() {
return useMutation({
mutationFn: (input: CreateShoutInput) => ShoutService.createShout(input),
});
}
Now we can use these hooks inside the useReplyToShout
hook.
As a first step, we replace the dependencies
object by a TypeScript interface and adjust the replyToShout
function accordingly.
import { Me, hasExceededShoutLimit, isAuthenticated } from "@/domain/me";
import { Image } from "@/domain/media";
import { Shout } from "@/domain/shout";
import { User, hasBlockedUser } from "@/domain/user";
import { useCreateShout } from "../mutations/create-shout";
import { useCreateShoutReply } from "../mutations/create-shout-reply";
import { useSaveImage } from "../mutations/save-image";
import { useGetMe } from "../queries/get-me";
import { useGetUser } from "../queries/get-user";
...
interface Dependencies {
me: ReturnType<typeof useGetMe>["data"];
recipient: ReturnType<typeof useGetUser>["data"];
saveImage: ReturnType<typeof useSaveImage>["mutateAsync"];
createShout: ReturnType<typeof useCreateShout>["mutateAsync"];
createReply: ReturnType<typeof useCreateShoutReply>["mutateAsync"];
}
export async function replyToShout(
{ shoutId, message, files }: ReplyToShoutInput,
{ me, recipient, saveImage, createReply, createShout }: Dependencies
) {
if (!isAuthenticated(me)) {
return { error: ErrorMessages.NotAuthenticated };
}
if (hasExceededShoutLimit(me)) {
return { error: ErrorMessages.TooManyShouts };
}
if (!recipient) {
return { error: ErrorMessages.RecipientNotFound };
}
if (hasBlockedUser(recipient, 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 };
}
}
Next, we need to rewrite the useReplyToShout
hook.
Instead of simply providing the dependencies object to the replyToShout
function and returning it, we
- gather all dependencies via the query and mutation hooks
- return a
mutateAsync
function (the name is random but makes the API of the hook consistent with react-query mutation hooks) - merge the loading states of all the query and mutation hooks
- merge the errors of both query hooks.
interface UseReplyToShoutInput {
recipientHandle: string;
}
export function useReplyToShout({ recipientHandle }: UseReplyToShoutInput) {
const me = useGetMe();
const user = useGetUser({ handle: recipientHandle });
const saveImage = useSaveImage();
const createShout = useCreateShout();
const createReply = useCreateShoutReply();
return {
mutateAsync: (input: ReplyToShoutInput) =>
replyToShout(input, {
me: me.data,
recipient: user.data,
saveImage: saveImage.mutateAsync,
createShout: createShout.mutateAsync,
createReply: createReply.mutateAsync,
}),
isLoading:
me.isLoading ||
user.isLoading ||
saveImage.isPending ||
createShout.isPending ||
createReply.isPending,
isError: me.isError || user.isError,
};
}
With this change the API requests to get me
and user
are sent as soon as the ReplyDialog
component is rendered. At the same time, both responses can be delivered from the cache if the data has been fetched previously.
Once the user replies to a shout they don’t have to wait for these requests anymore improving the overall user experience.
Another advantage of this approach: While we use react-query for server data management we can test the replyToShout
function and all its business logic in isolation.
With these changes we can now simplify our ReplyToDialog
component. We don’t need the isLoading
and hasError
states anymoreas those are provided by the adjusted useReplyToShout
hook.
import { useState } from "react";
import { useGetMe } from "@/application/queries/get-me";
import { useReplyToShout } from "@/application/reply-to-shout";
import { LoginDialog } from "@/components/login-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { isAuthenticated } from "@/domain/me";
...
export function ReplyDialog({
recipientHandle,
children,
shoutId,
}: ReplyDialogProps) {
const [open, setOpen] = useState(false);
const [replyError, setReplyError] = useState<string>();
const replyToShout = useReplyToShout({ recipientHandle });
const me = useGetMe();
if (me.isError || !isAuthenticated(me.data)) {
return <LoginDialog>{children}</LoginDialog>;
}
async function handleSubmit(event: React.FormEvent<ReplyForm>) {
event.preventDefault();
const message = event.currentTarget.elements.message.value;
const files = Array.from(event.currentTarget.elements.image.files ?? []);
const result = await replyToShout.mutateAsync({
recipientHandle,
message,
files,
shoutId,
});
if (result.error) {
setReplyError(result.error);
} else {
setOpen(false);
}
}
...
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* the rest of the component */}
</Dialog>
);
}
Another advantage of extracting the business logic to the useReplyToShout
hook becomes obvious now:
We changed the underlying mechanism of server data management inside the hook quite a bit. But still the adjustments in the component were minimal.
Next Refactoring Steps
That was it for this time. We succesfully integrated react-query into our React applications architecture. Next time we’ll refactor the folder structure a bit to match a more common feature-driven folder structure.
Top comments (0)