AppRouter(RSC)で簡単にグローバルステートを導入して、SCでもCCでもServerActionsでも横断的に状態を共有する方法
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
ファイルを作成してください。
- GitHub
https://github.com/settings/applications/2435178 - OpenAI
https://platform.openai.com/ - Vercel
https://vercel.com/storage/kv
※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
を使用して、グローバルステートの実装をしてみます。
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
の値)を横断的に共有できています。
※画面に debug 情報が表示できないもの、主にサーバー側のログはターミナルから確認してください。
最後に
今回のリポジトリはこちらです。
Discussion