🌐

AppRouter(RSC)で簡単にグローバルステートを導入して、SCでもCCでもServerActionsでも横断的に状態を共有する方法

2024/01/31に公開

VTeacher所属のSatokoです。

フロントエンドエンジニアとバックエンドエンジニアを兼任しています。
王道なテクノロジーと少しだけGeekなテクノロジーを組み合わせた選定が好みです🤤

Next.js AppRouter (React Server Components) を採用する企業が増えていますね!
今回は弊社でやっている、サーバーコンポーネント(SC)、クライアントコンポーネント(CC)、そしてServerActionsを問わず、横断的に状態を共有する方法をご紹介します!

こんな感じで、 AppRouter (RSC) 環境下で、ある枝の末端にあるクライアントコンポーネントから、別の枝にあるサーバーコンポーネントへ状態を共有したい場合を想定しています。
バケツリレー(親から子への連鎖的な受け渡し)を避けたいときです。

(それでも・・・わたしはバケツリレー推奨派です・・・)

はじめに

Vercel が提供する Next.js のスターターテンプレートがあります。

Next.js starter templates and themes
Discover Next.js templates, starters, and themes to jumpstart your application or website build.

便利ですよね😳

この中に ChatGPT に似たチャットUIを実装するデモが含まれています。
今回はそれ ( nextjs-ai-chatbot ) にグローバルステートを導入していきます。

Next.js AI Chatbot
A full-featured, hackable Next.js AI chatbot built by Vercel

スキルセットや機能は以下の通りで、一般的なサービス開発に必要な最低限の要素が含まれています。
サインイン・サインアウト機能には NextAuth.js を使用しています。

- Next.js App Router
- React Server Components (RSCs), Suspense, and Server Actions
- Vercel AI SDK for streaming chat UI
- Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain
- shadcn/ui
  - Styling with Tailwind CSS
  - Radix UI for headless component primitives
  - Icons from Phosphor Icons
- Chat History, rate limiting, and session storage with Vercel KV
- NextAuth.js for authentication

あと、これはVercelのチームによって提供されています。つまり本家の人たちの実装コードです!

This library is created by Vercel and Next.js team members, with contributions from:
Jared Palmer (@jaredpalmer) - Vercel
Shu Ding (@shuding_) - Vercel
shadcn (@shadcn) - Vercel

ローカル環境

リポジトリ

git clone や Fork で取り込んでください。

環境変数(.env)

コードを確認するだけでなく、実際に動作させたい場合は、OpenAIのキーと、Vercel KV(無料プランでも問題ありません)が必要です。また、サインイン機能にはGitHubアカウントも必要になります。

.env.exampleをコピーして.envファイルを作成してください。

※AUTH_SECRET は https://generate-secret.vercel.app/32 にアクセスしたときに表示される値で大丈夫です。

.env.example
# You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview
# Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys
OPENAI_API_KEY=XXXXXXXX

# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
AUTH_SECRET=XXXXXXXX
# Create a GitHub OAuth app here: https://github.com/settings/applications/new
# For info on authorization callback URL visit here: https://authjs.dev/reference/core/providers_github#callback-url
AUTH_GITHUB_ID=XXXXXXXX
AUTH_GITHUB_SECRET=XXXXXXXX
# Support OAuth login on preview deployments, see: https://authjs.dev/guides/basics/deployment#securing-a-preview-deployment
# Set the following only when deployed. In this example, we can reuse the same OAuth app, but if you are storing users, we recommend using a different OAuth app for development/production so that you don't mix your test and production user base.
# AUTH_REDIRECT_PROXY_URL=https://YOURAPP.vercel.app/api/auth

# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and 
KV_URL=XXXXXXXX
KV_REST_API_URL=XXXXXXXX
KV_REST_API_TOKEN=XXXXXXXX
KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX

起動

Node.jsの環境を整えて、インストールします。

※pnpmの場合

pnpm install
pnpm dev

開く

GitHubアカウントでサインインできます。サインインに成功すると次の画面になります。

Send a message と表示されているテキストフィールドに質問を入力し、送信ボタンをクリックして、OpenAI APIからの回答が返ってくることを確認してください。

  • 大規模言語モデル(LLM)を変更したい場合

gpt-3.5-turbo を使うように書かれているので、4系に変えたい場合は app/api/chat/route.ts を書き換えます。

  const res = await openai.chat.completions.create({
    // model: 'gpt-3.5-turbo',
    // model: 'gpt-4',
    model: 'gpt-4-turbo-preview',
    messages,
    temperature: 0.7,
    stream: true
  })

※OpenAI API は課金制なので、 管理画面 で使用状況を確認しておいてください。

フォルダ構成

nextjs-ai-chatbot のフォルダ構成は以下の通りです。

Project
├── app
│   ├── (chat)
│   │   ├── [id]
│   │   │   └── page.tsx
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── layout.tsx
│   └── actions.ts '<-------- * Server Actions (use server)'
│
├── components '<------------ コンポーネント置き場'
│   ├── chat.tsx  '<--------- * Client Component (use client)'
│   ├── sidebar-list.tsx '<-- * Server Component'
│   └── prompt-form.tsx '<--- * Client Component ( 無印 )'
│
├── lib
│   └── hooks '<------------- カスタムフック置き場'
├── middleware.ts
└── public '<---------------- リソースファイル置き場'

このデモとは直接関係ありませんが、 Vercel を使用する際に非常に便利な middleware の活用してきたいですよね。
ちなみに、「無印」と記されている部分についてですが、 use client が明示的に記載されていなくても、呼び出し元(先祖)がクライアントサイドである場合は、そのコードも実質的に ClientComponent として機能します。 SharedComponent のような利用を想定していない場合は、明確に use client を先頭に記述したほうが良いですね。

グローバルステートを導入する

本題に入ります。この RSC (Next.js AppRouter) で構成されているプロジェクトにグローバルステートを導入してみます。

RSC の特性上、 Recoil など従来のグローバルステート管理戦略は適用できません。そのため、 RSC に特化したグローバルステート管理ライブラリの利用が必要になります。
今回は nrstate を使用して、グローバルステートの実装をしてみます。

https://www.npmjs.com/package/nrstate

nrstate は以下の要素を表しています

  • N:Next.js
  • R:React Server Components
  • State:状態管理

これは Next.js の AppRouter に特化したグローバルステート管理ライブラリです。

nrstateをインストール

※pnpmの場合

pnpm install nrstate --save-dev
pnpm install nrstate-client --save-dev

ページ全体の状態(state)を見極める

nrstate には PageState という概念があります。これは、ページ全体のステートを考えるというものです。これにより、そのページが複数のコンポーネントで構成されていたとしても、コンポーネント間で横断的にステートを取得できるようになります。

nextjs-ai-chatbot では、ユーザーが入力するただひとつの値( message )をグローバルなステートとして扱うのが適していると思いますので、これを PageState として使うことにします。

  • 公式サイト

  • ポイント

    • ページ全体を考慮してグローバルステートを定めます(複数のコンポーネントにまたがって適用されます)。
    • グローバルステートの設定(set)はクライアントサイドでのみ可能です。
    • グローバルステートの取得(get)はクライアントサイドでもサーバーサイドでも可能です。
    • リフレッシュ処理は自動的に行われます。
    • RSCの基本原則として、最初はサーバーコンポーネントから始まります。

では、実装していきます。

PageStateをつくる

次のようなファイルを作成して、PageStateを定義します。

  • app/PageStateChat.tsx (※新規作成します)
export type PageStateChat = { 
    message: string
}

export const initialPageStateChat = { 
    message: '' 
} satisfies PageStateChat

export const pathChat = '/'
  • PageStateChat は、このPageStateの型を定義しています。
  • initialPageStateChat はデフォルト値を指定しています。
  • pathChat は対象となるパスを示しており、今回はルート( / )に設定しています。これにより、ルート配下の全ての場所でPageStateを参照できるようになります。

PageStateのプロバイダーを実装します。

nextjs-ai-chatbot のルートには page.tsx が無いため、 代替として layout.tsx に PageStateProvider を実装します。
PageStateProviderは必ずサーバーコンポーネントに実装するようにしてください(通常は page.tsx や layout.tsx はサーバーコンポーネントにしていると思いますので、問題ないと思いますが…)

  • app/layout.tsx

importします。

import { currentPageState } from 'nrstate/PageStateServer'
import PageStateProvider from 'nrstate-client/PageStateProvider'
import {
  PageStateChat,
  initialPageStateChat,
  pathChat
} from '@/app/PageStateChat'

renderのところ、既存コードをPageStateProviderで囲むだけです。

export default function RootLayout({ children }: RootLayoutProps) {
  return (
  
    <PageStateProvider
      current={currentPageState<PageStateChat>(initialPageStateChat, pathChat)}
    >
      <html lang="en" suppressHydrationWarning>
        ...(略)...
      </html>
    </PageStateProvider>
  
  )
}

これだけで設定は終わりです。簡単ですね。

PageStateに値を設定(set)する

components 配下は、汎用的なコンポーネントの配置場所ですが、今回は便宜上、こちらに手を入れていきます。

ClientComponent

PageStateはクライアントコンポーネント(CC)からのみ設定可能です。
定義したグローバルステートである message の変更イベントを利用して、 PageState を更新します。

  • components/prompt-form.tsx

prompt-form.tsx には use client の記述が先頭にありませんが、その呼び出し元(祖先)がクライアントコンポーネント(CC)であるため、クライアントコンポーネントとして実行されています。

PageState を import します。

import { usePageState } from 'nrstate-client/PageStateClient'
import { PageStateChat, pathChat } from '@/app/PageStateChat'

PageState のフックを使用します。

export function PromptForm({...()...}) {
  const [pageState, setPageState] = usePageState<PageStateChat>()
  
  ...()...
}

入力後、送信ボタンをクリックする( onSubmit イベント)タイミングで、入力された値( input )を使って PageState を更新します。

    <form
      onSubmit={async e => {
        ...()...
	
        setPageState(
          {
            ...pageState,
            message: input
          },
          pathChat
        )
	
	...()...

PageStateから値を取得(get)する

ClientComponent

  • components/chat.tsx

PageState を importします。

import { usePageState } from 'nrstate-client/PageStateClient'
import { PageStateChat, pathChat } from '@/app/PageStateChat'

PageState のフックを使用して、 PageState ( message ) を取得できます。

export function Chat(...()...) {

  const [pageState] = usePageState<PageStateChat>()
  const { message } = pageState
  
  ...()...
}

debug 用にグローバルステートを画面に表示してみます。

<p className="text-sm text-blue-500">
  ClientComponent(use client): message={message}
</p>

ServerComponent

次はサーバー側です。
nrstate はクライアント側のみ値が変更ができるようになっており、サーバー側は値の参照のみが可能です。

  • components/sidebar-list.tsx

PageState を import します。
※サーバー側は getPageState を使います。

import { getPageState } from 'nrstate/PageStateServer'
import {
  PageStateChat,
  initialPageStateChat,
  pathChat
} from '@/app/PageStateChat'

PageState のフックを使用して、 PageState を取得します。

export async function SidebarList(...()...) {

  const pageState = getPageState<PageStateChat>(initialPageStateChat, pathChat)
  const { message } = pageState
  
  console.log('ServerComponent', message)
  ...()...
}

debug 用に PageState を画面に表示します。

<p className="text-sm text-orange-500">
  ServerComponent message={message}
</p>

ServerActions

ServerActions もサーバー側の機能であるため、サーバーコンポーネント(SC)と同様の方法で扱います。

  • app/actions.ts

PageState を import します。
サーバー側は getPageState を使います。

import { getPageState } from 'nrstate/PageStateServer'
import {
  PageStateChat,
  initialPageStateChat,
  pathChat
} from '@/app/PageStateChat'

PageState のフックを使用して、 PageState を取得します。

export async function removeChat(...()...) {

  const pageState = getPageState<PageStateChat>(initialPageStateChat, pathChat)
  const { message } = pageState
  
  console.log('ServerActions(use server) message=', message)
  ...()...

※66行目、 nextjs-ai-chatbot に元々のバグがあるので、次のように修正しておいてください。

  if (String(uid) !== session?.user?.id) {
    ...()...
  }

PageStateを明示的に削除する

明示的に PageState を削除する場合は次のようにします。
(クライアントコンポーネントで可能)

import { clearPageState } from 'nrstate-client/PageStateClient'
import { pathChat } from '@/app/demo/PageStateChat'
clearPageState(pathChat)

動作確認

では、チャット機能を試してみましょう!

次の動画のように、サーバーコンポーネントであっても、クライアントコンポーネントであっても、ServerActionsであっても、グローバルステート( PageState の値)を横断的に共有できています。

https://www.youtube.com/watch?v=Lzyxijqf6Ik

※画面に debug 情報が表示できないもの、主にサーバー側のログはターミナルから確認してください。

最後に

今回のリポジトリはこちらです。

Discussion