The unopinionated nature of React is a two-edged sword:
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.
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);...AuseEffect(() => {AUserService.getMe()A.then(isUserAuthenticated)A.then(setIsAuthenticated)A.catch(() => setHasError(true))A.finally(() => setIsLoading(false));A}, []);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>);}
If you ever used react-query you probably know the problem.
These are just two things that react-query can help us with.
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();Aconst me = useGetMe();Aif (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?
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) {...Aconst 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 ?? []);Aconst result = await replyToShout({ArecipientHandle,Amessage,Afiles,AshoutId,A});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";...Aconst dependencies = {AgetMe: UserService.getMe,AgetUser: UserService.getUser,AsaveImage: MediaService.saveImage,AcreateShout: ShoutService.createShout,AcreateReply: ShoutService.createReply,A};export async function replyToShout({ recipientHandle, shoutId, message, files }: ReplyToShoutInput,{ getMe, getUser, saveImage, createReply, createShout }: typeof dependencies) {Aconst me = await getMe();if (hasExceededShoutLimit(me)) {return { error: ErrorMessages.TooManyShouts };}Aconst 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) {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 };}}export function useReplyToShout() {return useCallback(A(input: ReplyToShoutInput) => replyToShout(input, dependencies),[]);}
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.
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";...Ainterface Dependencies {Ame: ReturnType<typeof useGetMe>["data"];Arecipient: ReturnType<typeof useGetUser>["data"];AsaveImage: ReturnType<typeof useSaveImage>["mutateAsync"];AcreateShout: ReturnType<typeof useCreateShout>["mutateAsync"];AcreateReply: ReturnType<typeof useCreateShoutReply>["mutateAsync"];A}export async function replyToShout({ shoutId, message, files }: ReplyToShoutInput,A{ me, recipient, saveImage, createReply, createShout }: Dependencies) {Aif (!isAuthenticated(me)) {return { error: ErrorMessages.NotAuthenticated };}Aif (hasExceededShoutLimit(me)) {return { error: ErrorMessages.TooManyShouts };}Aif (!recipient) {return { error: ErrorMessages.RecipientNotFound };}Aif (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
mutateAsync
function (the name is random but makes the API of the hook consistent with react-query mutation 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 ?? []);Aconst 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.
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.