Path To A Clean(er) React Architecture (Part 8)React-Query

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

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.

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.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.
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.