アクションとリクエスト
Server Action は、クライアントから呼べてサーバーで動く関数で、このモードにはその届き先があります。このページは、アクションの書き方、JavaScript が無くても動くフォーム、redirect() での終え方、ページがリクエストを読む方法を説明します。
'use server' で宣言する
先頭に 'use server' を書いたモジュールの export は、すべて Server Action になり、どれも async でなければなりません。クライアントコンポーネントはそれを import して呼べ、呼び出しはサーバーで動きます。
// src/routes/_data/talks.server.ts
import 'server-only';
const talks: string[] = [];
export const saveTalk = (title: string): Promise<void> => {
talks.push(title);
return Promise.resolve();
};// src/routes/_parts/actions.ts
'use server';
import { saveTalk } from '../_data/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: 'a title is required' };
}
await saveTalk(title);
return {};
}useActionState にアクションを渡すと、フォームの action と、前回の結果と、送信中かどうかが返ります。アクションは前回の状態と FormData を受け取り、次の状態を返します。
// src/routes/_parts/talk-form.tsx
'use client';
import { useActionState } from 'react';
import { createTalk } from './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 ? null : <p role="alert">{state.error}</p>}
</form>
);
}1 往復で画面まで新しくなる
アクションを呼ぶと、サーバーは現在のページを描き直し、戻り値と一緒に返します。呼び出し側が値を受け取った時点で、画面もすでに新しくなっています。往復は 2 回ではなく 1 回です。
JavaScript が読み込まれる前から動くフォーム
React はアクションを識別するフィールドを HTML に描きます。それを送るのは普通のフォーム送信で、サーバーはアクションを実行し、同じページを描き直した HTML で答えます。useActionState の結果はこの往復の後にも残るので、同じコンポーネントが JavaScript の有無を知らないまま、どちらでも動きます。
'use server' と server-only は別のことを言う
'use server' はクライアントが呼んでよい関数、つまりサーバーで動く関数を印付けます。server-only はクライアントが決して届いてはいけないモジュールを印付けます。アクションのモジュールに要るのは前者だけです。秘密やデータベースのクライアントは *.server.ts に置き、アクションからそれを import します。上の createTalk がその形です。
アクションのモジュールを *.server.ts と名付けないのも同じ理由です。クライアントから import されることが、このファイルの目的だからです。
redirect() で終える
@k8ordo/server/runtime の redirect(to) は、訪問者を別の場所へ送ってアクションを終えます。値を返すのではなく throw するので、その後の行は走りません。Server Component が <form action> にアクションを直接渡す形なら、クライアントコンポーネントは 1 つも要りません。
// src/routes/_parts/leave.ts
'use server';
import { redirect } from '@k8ordo/server/runtime';
import { saveTalk } from '../_data/talks.server';
export async function addAndLeave(formData: FormData): Promise<void> {
const title = formData.get('title');
if (typeof title === 'string' && title !== '') {
await saveTalk(title);
}
redirect('/products');
}// src/routes/page.tsx
import { addAndLeave } from './_parts/leave';
export default function HomePage() {
return (
<form action={addAndLeave}>
<input aria-label="title" name="title" />
<button type="submit">add and leave</button>
</form>
);
}JavaScript なしで送られたフォームには 303 と location で答え、ブラウザは行き先を GET で読み込みます。クライアントのランタイムからの呼び出しには、行き先へ移動するよう伝えるペイロードで答え、ルーターがそこへ遷移します。リダイレクトしたアクションは、ページを描きません。
redirect() は throw で終わるので、try の中で呼ぶと catch がそれを受け取ってしまいます。try の外で呼びます。
ページの描画中に訪問者を別の場所へ送る API はありません。redirect() が効くのは Server Action の中だけで、描画中に呼ぶとただのエラーとして throw され、error.tsx か 500 になります。移転した URL には redirect.ts を置きます。 エラーとリダイレクト
リクエストを読む
ページとレイアウト(と not-found.tsx)は、params と pathname の横で request を受け取ります。中身はヘッダーと、名前ごとにパースされた cookie です。
// src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
export default function RootLayout({ children, request }: LayoutProps<'/'>) {
const theme = request.cookies.get('theme') === 'dark' ? 'dark' : 'light';
const language = request.headers.get('accept-language') ?? 'en';
return (
<html data-theme={theme} lang={language.split(',')[0]}>
<body>{children}</body>
</html>
);
}| フィールド | 型 | 中身 |
|---|---|---|
headers | Headers | リクエストのヘッダー |
cookies | ReadonlyMap<string, string> | Cookie ヘッダーを名前ごとにパースしたもの。同じ名前が 2 度あれば最初のものを取り、値を囲む引用符を外して URL デコードします(デコードできなければ送られたまま)。 |
PageProps と LayoutProps が request を持つのは、生成された .k8ordo/register.gen.ts が、このモードにはリクエストがあると言っているからです。@k8ordo/server/runtime の RouteRequest はその型で、下のコンポーネントに prop として渡すときに使います。クライアントコンポーネントには丸ごと渡さず、要る値だけを取り出して渡します。Headers は境界を越えられません。
// src/routes/_parts/greeting.tsx
import type { RouteRequest } from '@k8ordo/server/runtime';
export function Greeting({ request }: { request: RouteRequest }) {
return <p>{request.cookies.get('name') ?? 'welcome'}</p>;
}ページには応答を書く手段がありません。ステータスも Set-Cookie も書けません。ページは描画であり、リクエストに答える描画は 2 つ目のハンドラになってしまうからです。
このフィールドはこのモードにしかありません。@k8ordo/static の下では生成される Page と Layout の型に request が無いので、それを読むページは型チェックで落ちます。search も含まれません。search は @k8ordo/state のもので、ブラウザで読みます。
別オリジンからの POST は拒まれる
Server Action は、POST できる場所ならどこからでも名前で呼べてしまう関数です。訪問者の cookie を付けたまま、別のサイトのフォームから送られることもあります。ハンドラは POST の Origin ヘッダーのホストが、答えている URL のホストと一致するときだけ受け付け、それ以外には 403 で答えます。この検査はアクションに限らずすべての POST に掛かるので、Origin を付けない curl や Webhook の POST も 403 になります。
プロキシの後ろで動かすときの注意は、リンク先にあります。 実行と配信
@k8ordo/form と組む
@k8ordo/form は、フォームの制約属性・メッセージ・サーバー側の検証を 1 つの zod スキーマから導きます。次は examples/server-basic のゲストブックで、JavaScript なしで送っても、フィールドごとのエラーと入力した値を持って戻ってきます。
// src/routes/_parts/guestbook-schema.ts
import * as z from 'zod/mini';
z.config(z.locales.en());
export const guestbookSchema = z.object({
name: z.string().check(z.minLength(1), z.maxLength(40)),
});// src/routes/_parts/guestbook.ts
'use server';
import { parseForm } from '@k8ordo/form/server';
import type { FormState } from '@k8ordo/form/server';
import { guestbookSchema } from './guestbook-schema';
const entries: string[] = [];
export async function sign(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = parseForm(guestbookSchema, formData);
if (!parsed.success) return parsed.state;
entries.push(parsed.data.name);
return {};
}// src/routes/_parts/guestbook-form.tsx
'use client';
import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';
import { sign } from './guestbook';
export function GuestbookForm({ fields }: { fields: FormFields<'name'> }) {
const [state, formAction] = useActionState(sign, {});
const form = useForm(fields, state);
const name = form.field('name');
return (
<form {...form.props} action={formAction}>
<input aria-label="name" {...name.input} />
<button type="submit">sign</button>
{name.error === undefined ? null : <p>{name.error}</p>}
</form>
);
}// src/routes/page.tsx
import { formFields } from '@k8ordo/form/server';
import { GuestbookForm } from './_parts/guestbook-form';
import { guestbookSchema } from './_parts/guestbook-schema';
const guestbookFields = formFields(guestbookSchema);
export default function HomePage() {
return <GuestbookForm fields={guestbookFields} />;
}スキーマから属性と文言を導くのは Server Component の側で、結果は素の JSON として props でクライアントに渡ります。zod はブラウザに届きません。 @k8ordo/form
動かすことで得られるもの
このモードが @k8ordo/static に対して持つのは次のものです。どれも要らなければ、@k8ordo/static が同じアプリをファイルに書き出します。文法も境界もハンドラも同じで、ハンドラがビルド時にルートごとに呼ばれるだけです。
- 知らない URL への、ホスティングの答えではなくアプリ自身の本物の 404
- 値の一覧が要らないパラメータ付きルート。カタログが変わっても再ビルドは要りません
- フォームが送れる Server Action と、そこからの
redirect() - ページから読めるリクエストのヘッダーと cookie