Lädt...


🔧 Path To A Clean(er) React Architecture (Part 8) - How Does React-Query Fit Into The Picture?


Nachrichtenbereich: 🔧 Programmierung
🔗 Quelle: dev.to

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.

Table Of Contents

  1. Problematic code example 1: Manually managing server data
    1. The problem: Boilerplate & state management
    2. The solution: Creating a react-query hook
  2. Problematic code example #2: Business logic and react-query
    1. The problem: Duplicate requests
    2. The solution: Using react-query data and mutations
  3. 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.

The React Job Simulator

...

🔧 Comparing All-in-One Architecture, Layered Architecture, and Clean Architecture


📈 42.01 Punkte
🔧 Programmierung

🔧 Path To A Clean(er) React Architecture (Part 7) - Domain Logic


📈 40.25 Punkte
🔧 Programmierung

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


📈 40.25 Punkte
🔧 Programmierung

🔧 Tìm Hiểu Về RAG: Công Nghệ Đột Phá Đang "Làm Mưa Làm Gió" Trong Thế Giới Chatbot


📈 39.49 Punkte
🔧 Programmierung

🎥 Does DNS Fit Into A Secure Architecture - Paul's Security Weekly #605


📈 34.88 Punkte
🎥 IT Security Video

🎥 Does DNS Fit Into A Secure Architecture – Paul’s Security Weekly #605


📈 34.88 Punkte
🎥 IT Security Video

🎥 Does DNS Fit Into A Secure Architecture – Paul’s Security Weekly #605


📈 34.88 Punkte
🎥 IT Security Video

🔧 Path To A Clean(er) React Architecture - Domain Entities & DTOs


📈 33.69 Punkte
🔧 Programmierung

🔧 Path To A Clean(er) React Architecture - API Layer & Data Transformations


📈 33.69 Punkte
🔧 Programmierung

🔧 Path To A Clean(er) React Architecture - A Shared API Client


📈 33.69 Punkte
🔧 Programmierung

🔧 Clean Architecture: Keeping Code Clean and Maintainable


📈 32.79 Punkte
🔧 Programmierung

🔧 The difference between clean code and clean architecture?


📈 32.79 Punkte
🔧 Programmierung

🔧 Using Chrome’s Document Picture-in-Picture API in React


📈 32.13 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 16 - Adding Data Persistence with Entity Framework Core


📈 28.07 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 13-Adding Validation Using Fluent Validation


📈 28.07 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 14-last step in Application Core Layer


📈 28.07 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 12-Creating, Updating, and Deleting Entities Using Commands


📈 28.07 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 11-Organizing the Code Using Features


📈 28.07 Punkte
🔧 Programmierung

🔧 What is Clean Architecture: Part 10 - Writing the Application Logic in the Request Handler


📈 28.07 Punkte
🔧 Programmierung

🔧 Mastering NestJS: Unleashing the Power of Clean Architecture and DDD in E-commerce Development — part 1


📈 28.07 Punkte
🔧 Programmierung

🪟 Microsoft Teams für Android jetzt mit Picture-in-Picture und mehr


📈 26.58 Punkte
🪟 Windows Tipps

🪟 How to make a picture-in-picture video on Windows 10


📈 26.58 Punkte
🪟 Windows Tipps

📰 Google Outs Chrome OS 72 with Android Improvements, Picture in Picture Support


📈 26.58 Punkte
📰 IT Security Nachrichten

📰 macOS 10.12 Sierra Released with Built-in Siri, Picture in Picture (PIP) Support


📈 26.58 Punkte
📰 IT Security

🪟 Microsoft News: Windows Subsystem for Android gets picture-in-picture support and more


📈 26.58 Punkte
🪟 Windows Tipps

📰 Firefox 72 Now Available to Download with Picture-in-Picture for Linux and macOS


📈 26.58 Punkte
📰 IT Security Nachrichten

🔧 New in Chrome 116: Document Picture-in-Picture API, notRestoredReasons property, and more!


📈 26.58 Punkte
🔧 Programmierung

🐧 How to make stream chat picture in picture mode


📈 26.58 Punkte
🐧 Linux Tipps

📰 How to watch YouTube in picture-in-picture mode on iOS 14


📈 26.58 Punkte
🖥️ Betriebssysteme

📰 How to make a picture-in-picture video on Windows 10


📈 26.58 Punkte
🖥️ Betriebssysteme

📰 How to watch Netflix in Picture-in-picture mode in Chrome


📈 26.58 Punkte
Web Tipps

📰 macOS 10.12 Sierra Released with Built-in Siri, Picture in Picture (PIP) Support


📈 26.58 Punkte
📰 IT Security

📰 Android-Apps unter Windows 11 können nun auch im Picture-in-Picture-Modus laufen


📈 26.58 Punkte
📰 IT Nachrichten

matomo