Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to Show Task Sequence Progress with React Suspense and RSC in Next.js #10

Open
innocces opened this issue Sep 9, 2024 · 4 comments

Comments

@innocces
Copy link
Contributor

innocces commented Sep 9, 2024

The original blog info

subject content
title How to Show Task Sequence Progress with React Suspense and RSC in Next.js
url blog url
author Nico Prananta
@innocces innocces changed the title [Recorder]: How to Show Task Sequence Progress with React Suspense and RSC in Next.js How to Show Task Sequence Progress with React Suspense and RSC in Next.js Sep 9, 2024
@innocces
Copy link
Contributor Author

innocces commented Sep 9, 2024

How to Show Task Sequence Progress with React Suspense and RSC in Next.js

Authors

Imagine you allow a user to make a purchase of a service or product. If you use one of the popular payment gateways, you can easily show the payment page provided by the payment gateway. Once the user completes the payment, the payment gateway usually does two things:

  1. Redirects the user to a confirmation or success page that you own.
  2. At the same time, it notifies your server that the payment is completed via a webhook.

Now say you want to show the steps that are performed on the confirmation or success page:

  1. Confirm that the payment is completed successfully. Sometimes your server might not have received the webhook from the provider yet by the time the user reaches the confirmation page. So the user needs to stay on this first step until the webhook is received and the payment is confirmed.
  2. Once the payment is confirmed, let's imagine that you need to call another third-party API to create a resource. For example, you might use a third-party API to create a personalized PDF file for the user.
  3. After the PDF is created, you need to upload it to a cloud storage service like AWS S3.
  4. Finally, you need to send an email to the user with the PDF file.

All of these steps won't happen instantly. So you want to show the user which step is currently being performed to keep the user informed. Something similar to this:

The solution

There are several ways to achieve this. One way is to use services like Inngest and Trigger.dev. Once you set up the tasks in one of those services, you can trigger the functions and then check the status of the tasks by calling the endpoints provided by the services.

Or you can simply create multiple route handlers and call them one after another from the client side. But for every endpoint call, you need to manually maintain the state of the progress and keep the UI in sync using useState or useEffect.

Both of these approaches are very doable, but they have a few drawbacks: they are tedious to code and maintain.

RSC and Suspense: The new way

If you haven't read, I wrote about a new way to fetch data in the era of React Server Components and Suspense. Using the technique described in the article, we can easily implement the confirmation page above.

This is how it will look like:

As shown in the video, every task is represented by a component that has either a checkmark icon or a spinner icon, a title, and a description which I call the StepComponent:

app/confirm/components.tsx

type Step = {
  title: string;
  description: string;
  work: Promise<any>;
};

type StepProps = Step & {
  isLast: boolean;
};

export function StepComponent({ title, description, work, isLast }: StepProps) {
  return (
    <li className={`ml-6 ${isLast ? "" : "mb-10"}`}>
      <span className="absolute -left-4 flex h-8 w-8 items-center justify-center rounded-full bg-white ring-4 ring-white dark:bg-gray-700 dark:ring-gray-900">
        <Suspense fallback={<StepIcon status={"in-progress"} />}>
          <Asyncable work={work}>
            <StepIcon status={"done"} />
          </Asyncable>
        </Suspense>
      </span>
      <Suspense fallback={<Title disabled={true}>{title}</Title>}>
        <Asyncable work={work}>
          <Title>{title}</Title>
        </Asyncable>
      </Suspense>
      <p className="text-sm">{description}</p>
    </li>
  );
}

const Asyncable = ({
  work,
  children,
}: {
  work: Promise<any>;
  children: React.ReactNode;
}) => {
  use(work); // tell React to suspend the component until the promise is resolved which will show the nearest fallback component

  return <>{children}</>;
};

const Title = ({
  children,
  disabled,
}: {
  children: React.ReactNode;
  disabled?: boolean;
}) => {
  return (
    <h3
      className={cn(
        `font-medium leading-tight text-green-500 dark:text-green-400`,
        disabled && "text-gray-500",
      )}
    >
      {children}
    </h3>
  );
};

The StepComponent receives a promise that represents the work that needs to be done. This promise is used in React's use hook to suspend the component until the promise is resolved. Once the promise is resolved, the component will render the checkmark icon. While the component is suspended, it will show the fallback component.

In this component, the icon and the color of the title are determined by whether the promise is resolved or not. If the promise is resolved, the icon is a checkmark and the title's color is green. Otherwise, the icon is a spinner and the title's color is gray.

In the era before Suspense, we would have to use conditional rendering to determine whether to show the icon or the spinner. But with Suspense, we can keep everything modular. We can think of the spinner as the fallback component while the checkmark is the main component. And since we want to show the fallback when the component is suspended, I created a helper component called Asyncable which is used to suspend the component until the promise is resolved:

<Suspense fallback={<Title disabled={true}>{title}</Title>}>
    <Asyncable work={work}>
        <Title>{title}</Title>
    </Asyncable>
</Suspense>

By making things composable like this, we can keep the main component clean, dumb, and only does one thing. In the example above, the Title component is only responsible for rendering the title and the color of the title based on the disabled prop. It doesn't have the responsibility of suspending the component. The Asyncable component does that. If the fallback component could be a completely different component, the Title component wouldn't even need to render different color conditionally.

Now let's talk about the tasks. First, I made a function that executes several tasks in sequence:

export function unsafe_createSequentialProcesses<T extends any[], R>(
  ...processes: [(arg?: any) => Promise<T[0]>, ...((arg: any) => Promise<any>)[]]
): Promise<R>[] {
  return processes.reduce((acc, process, index) => {
    if (index === 0) {
      return [process(undefined)]
    }
    return [...acc, acc[acc.length - 1].then(process)]
  }, [] as Promise<any>[])
}

The unsafe_createSequentialProcesses function takes an array of functions that returns a promise and executes them in sequence. The resolved value of the promise is passed to the next promise in the array. The unsafe_createSequentialProcesses function then returns an array of promises that represent the sequence of tasks without awaiting them.

I prefixed the function with unsafe_ because it's not strongly typed. I tried to make it strongly typed but my TypeScript skills are not good enough to do that. Not even LLMs can help me 🫠. If you know how to strongly type this function, please let me know!

We can use this function in a Next.js page component which is a React Server Component like this:

app/confirm/page.tsx

export default async function AsyncWorks({
  params: { id },
}: {
  params: { id: string };
}) {
  const [first, second, third] = unsafe_createSequentialProcesses(
    () => firstProcess(id),
    secondProcess,
    thirdProcess,
  );

  return (
    <div className="flex flex-col space-y-4 px-4 py-8">
      <VerticalSteps>
        <StepComponent
          title="This is process 1"
          description="It starts immediately when the page is loaded. After it finishes, the UI will automatically update and show the green checkmark."
          work={first}
          isLast={false}
        />
        <StepComponent
          title="This is process 2"
          description="This process will run after the first one finishes."
          work={second}
          isLast={false}
        />
        <StepComponent
          title="This is process 3"
          description="While waiting, the component is suspended and shows the loader."
          work={third}
          isLast={true}
        />
      </VerticalSteps>
    </div>
  );
}

The firstProcess, secondProcess, and thirdProcess are async functions. We pass down these promises to each of the StepComponent components. The StepComponent component will suspend the component until the passed promise is resolved. Once the promise is resolved, the component will show the checkmark icon. While the component is suspended, it will show the loader.

As you can see, a lot of things happen automatically. We just need to make sure the tasks are executed sequentially in the server and the UI is updated automatically. We don't need to manually fetch statuses of the tasks from the client component. In result, we don't need to maintain the states of the progresses and keep the UI in sync using useState or useEffect.

Conclusion

I really like this pattern. It doesn't feel tedious. Everything is composed in a modular way. The React components are clean and only do one thing.

The only drawback of this approach is that a task can only be in one of the two states: pending or done. It doesn't have the waiting state. This is due to the nature of React's Suspense which is based on the nature of Promise. I have an idea to solve this problem but that's an idea for another day.

Another thing to note if you deploy your app to Vercel, you may want to increase the serverless function timeout to avoid timeout errors.


@innocces
Copy link
Contributor Author

innocces commented Sep 9, 2024

如何在 Next.js 中使用 React Suspense 和 RSC 显示任务序列进度

作者

想象一下,您允许用户购买服务或产品。如果您使用流行的支付网关之一,您可以轻松显示该支付网关提供的支付页面。一旦用户完成支付,支付网关通常会做两件事:

  1. 将用户重定向到您拥有的确认或成功页面。
  2. 同时通过webhook通知您的服务器支付完成。

现在假设您要显示在确认或成功页面上执行的步骤:

1.确认支付成功。有时,当用户到达确认页面时,您的服务器可能尚未收到来自提供商的 Webhook。因此,用户需要继续执行第一步,直到收到 Webhook 并确认付款。
2. 确认付款后,假设您需要调用另一个第三方API来创建资源。例如,您可以使用第三方 API 为用户创建个性化 PDF 文件。
3. PDF创建后,您需要将其上传到AWS S3等云存储服务。
4. 最后,您需要向用户发送一封包含 PDF 文件的电子邮件。

所有这些步骤都不会立即发生。因此,您希望向用户显示当前正在执行哪个步骤,以便让用户了解情况。与此类似的东西:

解决方案

有多种方法可以实现这一目标。一种方法是使用 InngestTrigger.dev 等服务。在其中一项服务中设置任务后,您可以触发这些功能,然后[检查任务的状态](https://www.inngest.com/docs/examples/fetch-run-status-and-通过调用服务提供的端点来输出#fetching-triggered-function-status-and-output)。

或者您可以简单地创建多个路由处理程序并从客户端依次调用它们。但对于每个端点调用,您需要手动维护进度状态并使用“useState”或“useEffect”保持 UI 同步。

这两种方法都非常可行,但它们有一些缺点:编码和维护都很繁琐。

RSC 和 Suspense:新方式

如果你还没有读过,我写了一篇关于 [React Server Components 和 Suspense 时代获取数据的新方法](/blog/simplify-data-fetching-with-rsc-suspense-and-use-api-in -下一个-js)。使用文章中描述的技术,我们可以轻松实现上面的确认页面。

它将如下所示:

如视频所示,每个任务都由一个组件表示,该组件具有复选标记图标或旋转图标、标题和描述(我将其称为“StepComponent”):

应用程序/确认/components.tsx

type Step = {
  title: string;
  description: string;
  work: Promise<any>;
};

type StepProps = Step & {
  isLast: boolean;
};

export function StepComponent({ title, description, work, isLast }: StepProps) {
  return (
    <li className={`ml-6 ${isLast ? "" : "mb-10"}`}>
      <span className="absolute -left-4 flex h-8 w-8 items-center justify-center rounded-full bg-white ring-4 ring-white dark:bg-gray-700 dark:ring-gray-900">
        <Suspense fallback={<StepIcon status={"in-progress"} />}>
          <Asyncable work={work}>
            <StepIcon status={"done"} />
          </Asyncable>
        </Suspense>
      </span>
      <Suspense fallback={<Title disabled={true}>{title}</Title>}>
        <Asyncable work={work}>
          <Title>{title}</Title>
        </Asyncable>
      </Suspense>
      <p className="text-sm">{description}</p>
    </li>
  );
}

const Asyncable = ({
  work,
  children,
}: {
  work: Promise<any>;
  children: React.ReactNode;
}) => {
  use(work); // tell React to suspend the component until the promise is resolved which will show the nearest fallback component

  return <>{children}</>;
};

const Title = ({
  children,
  disabled,
}: {
  children: React.ReactNode;
  disabled?: boolean;
}) => {
  return (
    <h3
      className={cn(
        `font-medium leading-tight text-green-500 dark:text-green-400`,
        disabled && "text-gray-500",
      )}
    >
      {children}
    </h3>
  );
};

StepComponent 接收一个代表需要完成的工作的承诺。这个 Promise 在 React 的 use 钩子中使用来挂起组件,直到 Promise 得到解决。一旦承诺得到解决,组件将呈现复选标记图标。当组件暂停时,它将显示后备组件。

在此组件中,标题的图标和颜色由 Promise 是否已解决决定。如果承诺得到解决,则图标为复选标记,标题颜色为绿色。否则,图标是一个旋转图标,标题的颜色是灰色的。

在 Suspense 之前的时代,我们必须使用条件渲染来确定是否显示图标或旋转器。但通过 Suspense,我们可以保持一切模块化。我们可以将旋转器视为后备组件,而复选标记是主要组件。由于我们希望在组件挂起时显示回退,因此我创建了一个名为“Asyncable”的辅助组件,用于挂起组件直到承诺得到解决:

<Suspense fallback={<Title disabled={true}>{title}</Title>}>
    <Asyncable work={work}>
        <Title>{title}</Title>
    </Asyncable>
</Suspense>

通过使事物像这样可组合,我们可以保持主要组件干净、愚蠢,并且只做一件事。在上面的例子中,“Title”组件只负责根据“disabled”属性渲染标题和标题的颜色。它没有暂停组件的责任。 “Asyncable”组件可以做到这一点。如果后备组件可以是完全不同的组件,则“Title”组件甚至不需要有条件地渲染不同的颜色。

现在我们来谈谈任务。首先,我创建了一个按顺序执行多个任务的函数:

export function unsafe_createSequentialProcesses<T extends any[], R>(
  ...processes: [(arg?: any) => Promise<T[0]>, ...((arg: any) => Promise<any>)[]]
): Promise<R>[] {
  return processes.reduce((acc, process, index) => {
    if (index === 0) {
      return [process(undefined)]
    }
    return [...acc, acc[acc.length - 1].then(process)]
  }, [] as Promise<any>[])
}

unsafe_createSequentialProcesses 函数接受一个返回 Promise 的函数数组并按顺序执行它们。 Promise 的解析值将传递给数组中的下一个 Promise。然后,“unsafe_createSequentialProcesses”函数返回一个代表任务序列的 Promise 数组,而不等待它们。

我在函数前面加上了“unsafe_”前缀,因为它不是强类型的。我尝试将其设为强类型,但我的 TypeScript 技能还不足以做到这一点。即使是法学硕士也无法帮助我🫠。如果您知道如何强类型化此函数,请告诉我!

我们可以在 Next.js page 组件中使用这个函数,它是一个 React 服务器组件,如下所示:

应用程序/确认/page.tsx

export default async function AsyncWorks({
  params: { id },
}: {
  params: { id: string };
}) {
  const [first, second, third] = unsafe_createSequentialProcesses(
    () => firstProcess(id),
    secondProcess,
    thirdProcess,
  );

  return (
    <div className="flex flex-col space-y-4 px-4 py-8">
      <VerticalSteps>
        <StepComponent
          title="This is process 1"
          description="It starts immediately when the page is loaded. After it finishes, the UI will automatically update and show the green checkmark."
          work={first}
          isLast={false}
        />
        <StepComponent
          title="This is process 2"
          description="This process will run after the first one finishes."
          work={second}
          isLast={false}
        />
        <StepComponent
          title="This is process 3"
          description="While waiting, the component is suspended and shows the loader."
          work={third}
          isLast={true}
        />
      </VerticalSteps>
    </div>
  );
}

“firstProcess”、“secondProcess”和“thirdProcess”是异步函数。我们将这些承诺传递给每个“StepComponent”组件。 StepComponent 组件将挂起组件,直到传递的 Promise 得到解决。一旦承诺得到解决,组件将显示复选标记图标。当组件暂停时,它将显示加载程序。

正如您所看到的,很多事情都是自动发生的。我们只需要确保任务在服务器中按顺序执行,并且 UI 会自动更新。我们不需要从客户端组件手动获取任务的状态。因此,我们不需要维护进度状态并使用“useState”或“useEffect”保持 UI 同步。

结论

我真的很喜欢这个图案。并不觉得乏味。一切都是以模块化的方式组成的。 React 组件很干净,只做一件事。

这种方法的唯一缺点是任务只能处于两种状态之一:“待处理”或“完成”。它没有“等待”状态。这是由于 React 的 Suspense 的本质是基于 Promise 的本质。我有一个解决这个问题的想法,但那是另一天的想法。

另一件需要注意的事情是,如果您将应用程序部署到 Vercel,您可能需要[增加无服务器函数超时](https://vercel.com/changelog/serverless-functions-can-now-run-up-to-5-分钟)以避免超时错误。


@innocces
Copy link
Contributor Author

innocces commented Sep 9, 2024

export function unsafe_createSequentialProcesses<T extends any[], R>(
  ...processes: [(arg?: any) => Promise<T[0]>, ...((arg: any) => Promise<any>)[]]
): Promise<R>[] {
  return processes.reduce((acc, process, index) => {
    if (index === 0) {
      return [process(undefined)]
    }
    return [...acc, acc[acc.length - 1].then(process)]
  }, [] as Promise<any>[])
}

=.- 因为是递归执行的。所以确定了第一个的类型后面的类型就跟着确定了。I mean 返回值

@innocces
Copy link
Contributor Author

innocces commented Sep 9, 2024

客户端使用 use 标记当前的组件是一个 promisable 的.

如果是服务端就直接 async component 就好了.

但是值得注意的是. 需要一个队列服务去 hold 主对应的任务. 也就是没成功的时候要一直卡主

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant