banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Server Action & Streamable UI

Note

This section is inspired by https://sdk.vercel.ai/docs/concepts/ai-rsc

The following code works in Next.js 14.1.2-canary.3, other versions may have changes.

This article was first published in the A Different Take on Next.js booklet that I am writing. Your support is welcome.

In LLM projects, information about streaming rendering is always visible.

image

Let's take a look at the request.

image

It turns out that this is a streaming RSC payload. This means that the UI updates are driven by the server's streaming RSC payload. The UI refreshes when the streaming RSC payload reads the next line.

In this section, we will use RSC to simply implement a streaming rendering message flow.

Server Action#

Before we start, we need to know that Server Action is actually a POST request, where the server calls the reference of the Server Action function and streams the execution result back via HTTP request.

In a Server Action, you must define an asynchronous method because the request is asynchronous; secondly, you must return data that can be serialized, such as functions cannot be returned.

We commonly use Server Action to refresh page data, such as using revalidatePath.

Let's give it a try.

```tsx filename="app/server-action/layout.tsx" import type { PropsWithChildren } from 'react'

export default async ({ children }: PropsWithChildren) => {
return (


Layout Render At: {Date.now()}

{children}

)
}

</Tab>
<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx"
'use client'

import { useState } from 'react'
import type { ReactNode } from 'react'

import { actionRevalidate } from './action'

export default () => {
  return (
    <div className="flex flex-col gap-4">
      <ServerActionRevalidate />
    </div>
  )
}

const ServerActionRevalidate = () => {
  return (
    <form
      action={async (e) => {
        await actionRevalidate()
      }}
    >
      <button type="submit">Revalidate this page layout</button>
    </form>
  )
}
```tsx filename="app/server-action/action.tsx" 'use server'

import { revalidatePath } from 'next/cache'

export const actionRevalidate = async () => {
revalidatePath('/server-action')
}

</Tab>
</Tabs>

![](https://nextjs-book.innei.in/images/4.gif)

When we click the button, the page re-renders, refreshing the latest server time without reloading the page.

## Using Server Action to Get Streamable UI

Let's imagine what would happen if we returned a ReactNode type in Server Action.

<Tabs>
<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx" {21}
'use client'

import { useState } from 'react'
import type { ReactNode } from 'react'

import { actionReturnReactNode } from './action'

export default () => {
  return (
    <div className="flex flex-col gap-4">
      <ServerActionRenderReactNode />
    </div>
  )
}

const ServerActionRenderReactNode = () => {
  const [node, setNode] = useState<ReactNode | null>(null)
  return (
    <form
      action={async (e) => {
        const node = await actionReturnReactNode()
        setNode(node)
      }}
    >
      <button type="submit">Render ReactNode From Server Action</button>
    </form>
  )
}
```tsx filename="app/server-action/action.tsx" 'use server'

export const actionReturnReactNode = async () => {
return

React Node

}

</Tab>
</Tabs>

![](https://nextjs-book.innei.in/images/5.gif)

We can see that when we click the button, the page renders a React Node. This React Node is returned by the Server Action.

We know that in the App Router, we can use Server Components. Server Components are stateless components that support asynchronous operations. The return value of an asynchronous component is actually a `Promise<ReactNode>`, and `ReactNode` is a serializable object.

So, what would happen if we used Suspense + asynchronous components?

<Tabs>
<Tab label="action.tsx">
  ```tsx filename="app/server-action/action.tsx" {3,7}
  export const actionReturnReactNodeSuspense = async () => {
    const Row = async () => {
      await sleep(300)
      return <div>React Node</div>
    }
    return (
      <Suspense fallback={<div>Loading</div>}>
        <Row />
      </Suspense>
    )
  }
  ```
</Tab>

<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx"
'use client'

import { useState } from 'react'
import type { ReactNode } from 'react'

import { actionReturnReactNodeSuspense } from './action'

export default () => {
  return (
    <div className="flex flex-col gap-4">
      <ServerActionRenderReactNode />
    </div>
  )
}

const ServerActionRenderReactNode = () => {
  const [node, setNode] = useState<ReactNode | null>(null)
  return (
    <form
      action={async (e) => {
        const node = await actionReturnReactNodeSuspense()
        setNode(node)
      }}
    >
      <button type="submit">Render ReactNode From Server Action</button>
    </form>
  )
}

image

We can see that when we click the button, the page renders a Suspense component, displaying Loading. Then, after waiting for the asynchronous component to load, it displays the React Node.

Using this feature, we can make a simple modification to this method, for example, we can implement a typewriter effect.

export const actionReturnReactNodeSuspenseStream = async () => {
  const createStreamableRow = () => {
    const { promise, reject, resolve } = createResolvablePromise()
    const Row = (async ({ next }: { next: Promise<any> }) => {
      const promise = await next
      if (promise.done) {
        return promise.value
      }

      return (
        <Suspense fallback={promise.value}>
          <Row next={promise.next} />
        </Suspense>
      )
    }) /* Our React typings don't support async components */ as unknown as React.FC<{
      next: Promise<any>
    }>

    return {
      row: <Row next={promise} />,
      reject,
      resolve,
    }
  }

  let { reject, resolve, row } = createStreamableRow()

  const update = (nextReactNode: ReactNode) => {
    const resolvable = createResolvablePromise()
    resolve({ value: nextReactNode, done: false, next: resolvable.promise })
    resolve = resolvable.resolve
    reject = resolvable.reject
  }

  const done = (finalNode: ReactNode) => {
    resolve({ value: finalNode, done: true, next: Promise.resolve() })
  }

  ;(async () => {
    for (let i = 0; i < typewriterText.length; i++) {
      await sleep(10)
      update(<div>{typewriterText.slice(0, i)}</div>)
    }
    done(
      <div>
        {typewriterText}

        <p>typewriter done.</p>
      </div>,
    )
  })()

  return <Suspense fallback={<div>Loading</div>}>{row}</Suspense>
}

In the code above, createStreamableRow creates a Row component wrapped in Suspense, using nested Promises. As long as the current promise's value is not done, the internal Suspense will not be resolved, allowing us to continuously replace new React Nodes inside.

In update, we replaced the previously resolved promise with a new one that has not been resolved, so Suspense will fallback to the value of the previous promise. This continues until the condition done === true is met.

The effect is as follows:

image

Using this Streamable UI feature, we can combine AI function calling to dynamically render various different UI components on the server side.

Warning

Due to the component updates driven by this streaming, the server needs to maintain a long connection, and each RSC payload that drives the update is a full update based on the previous one. Therefore, in the case of long texts, the amount of data transmitted can be very large, potentially increasing bandwidth pressure.

Additionally, maintaining a long connection on serverless platforms like Vercel can consume a lot of computing resources, and ultimately your bill may become uncontrollable.

All code examples above can be found at: demo/steamable-ui

Reference: https://sdk.vercel.ai/docs/concepts/ai-rsc#create-an-airsc-instance-on-the-server

This article was synchronized and updated to xLog by Mix Space. The original link is https://innei.in/posts/programming/nextjs-rsc-server-action-and-streamable-ui

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.