Server Action
serverモードだけで使えます。フォームの送信をServer Actionでサーバーに渡して処理し、必要ならredirect()で別のページへ移します。
このページの内容
アクションの定義
src/lib/actions.ts'use server';
import { saveTalk } from './talks.server';
export type TalkState = { error?: string };
export async function createTalk(
_previous: TalkState,
formData: FormData,
): Promise<TalkState> {
const title = formData.get('title');
if (typeof title !== 'string' || title === '') {
return { error: 'Enter a title' };
}
await saveTalk(title);
return {};
}先頭に'use server'を書いたモジュールのexportは、すべてServer Actionになります。どれもasync関数にします。
useActionStateに渡すアクションは、前回の結果とFormDataを受け取り、次の結果を返します。
src/components/talk-form.tsx'use client';
import { useActionState } from 'react';
import { createTalk } from '../lib/actions';
export function TalkForm() {
const [state, formAction, pending] = useActionState(createTalk, {});
return (
<form action={formAction}>
<input aria-label="Title" name="title" />
<button disabled={pending} type="submit">
Add
</button>
{state.error !== undefined && (
<p role="alert">{state.error}</p>
)}
</form>
);
}Client Componentでは、アクションをuseActionStateに渡します。返ってくるformActionをフォームのactionに渡すと、送信でアクションが呼ばれます。pendingは送信の途中だけtrueになります。
呼び出しの往復
アクションを呼ぶと、サーバーは今のページを描き直し、アクションの戻り値と一緒に返します。呼び出した側が戻り値を受け取った時点で、画面も新しくなっています。往復は1回です。
読み込み前の送信
Reactは、どのアクションを呼ぶかを示す隠し入力欄をHTMLに描きます。JavaScriptの読み込み前に送信すると、普通のフォームの送信になります。サーバーはアクションを実行し、同じページを描き直したHTMLを返します。
useActionStateは、この送信のあとの描画でもアクションの結果を返します。同じコンポーネントが、JavaScriptの読み込み前と後のどちらでも動きます。
'use server'とserver-only
'use server'は、ブラウザから呼んでよい関数に付けます。server-onlyは、ブラウザに渡してはいけないモジュールに付けます。アクションのモジュールに要るのは'use server'だけです。
秘密の値やデータベースのクライアントは*.server.tsに置き、アクションからimportします。ブラウザが受け取るのはアクションの参照だけなので、アクションから*.server.tsをimportしてもserver-onlyのエラーになりません。
アクションのモジュールはブラウザからimportされるので、*.server.tsとは名付けません。server-onlyの詳しい使い方はサーバーとブラウザの境界を見てください。
redirect()による遷移
src/lib/add-talk.ts'use server';
import { href } from '@k8ordo/framework';
import { redirect } from '@k8ordo/framework/server';
import { saveTalk } from './talks.server';
export async function addTalk(formData: FormData): Promise<void> {
const title = formData.get('title');
if (typeof title === 'string' && title !== '') {
await saveTalk(title);
}
redirect(href('/talks'));
`href()`で作ると、Viteの`base`の下で配信するときも`base`が付く
}src/routes/talks/new/page.tsximport { addTalk } from '../../../lib/add-talk';
export default function NewTalkPage() {
return (
<form action={addTalk}>
<input aria-label="Title" name="title" />
<button type="submit">Add</button>
</form>
);
}送信のあとに別のページへ移すときは、@k8ordo/framework/serverのredirect()でアクションを終えます。Server Componentが<form action>にアクションを直接渡せば、Client Componentは要りません。
落とし穴
redirect()は値を返さず、例外でアクションを終えます。その後の行は実行されません。tryの中で呼ぶとcatchが受け取ってしまうので、tryの外で呼んでください。
JavaScriptの読み込み前に送られたフォームには、サーバーが303とLocationヘッダーを返します。ブラウザは行き先をGETで読み込みます。クライアントのランタイムからの呼び出しには行き先を含むペイロードを返し、ルーターがそこへ遷移します。
redirect()は渡したURLにbaseを付けません。ページの描画中にリダイレクトするAPIはなく、ページのURLを変えたときはredirect.tsを置きます。書き方はエラーとリダイレクトを見てください。
アクションのリクエスト
アクションの呼び出しは、画面に出ているページのURLへ送られます。そのリクエストでも、ページとその上のレイアウトのparamsSchemaが実行されます。
たとえば/ja/talks/newから送られたアクションでは、@k8ordo/i18nのロケールがjaになります。@k8ordo/formのparseFormが作るzodのエラーも、ページと同じ言語になります。
アクションの中では、guard.tsと同じくcookies()、responseHeaders()とrequestHeaders()が使えます。使い方はリクエストとCookieを見てください。
別のサイトからの送信
Server Actionは、POSTを送れる場所ならどこからでも呼べます。そのためサーバーは、ページのURLへ送られたPOSTのOriginヘッダーを確かめます。route.tsへのPOSTは確かめません。Originのホストがリクエスト先のホストと一致しないPOSTと、Originの無いPOSTは、アクションを実行する前に403を返します。
プロキシの後ろで動かすときの注意はデプロイを見てください。
@k8ordo/formとの組み合わせ
src/lib/actions.ts'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { talkSchema } from './talk-schema';
import { insertTalk } from './talks.server';
export async function createTalk(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(talkSchema, formData);
if (!parsed.success) return parsed.state;
await insertTalk(parsed.data);
return {};
}@k8ordo/formを使うと、入力欄の制約とエラーの文言、サーバーでの検証を1つのzodのスキーマから作れます。アクションではparseFormで検証し、通らなかったときはparsed.stateを返します。
フォームの側の書き方は@k8ordo/formを見てください。