サーバー関数を送信先にする
<form> の action に POST のサーバー関数を渡すと、そのフォームはサーバー関数に送信されます。フィールドの値は、サーバー関数の input になります。
// src/lib/posts.ts
import { defineServerFn } from "sobria/server";
import { z } from "zod";
import { db } from "./db.ts";
export const addPost = defineServerFn({
input: z.object({ title: z.string().min(1) }),
})(async (c, { input }) => {
db.posts.insert(input.title);
});// src/routes/page.tsx
import { db } from "../lib/db.ts";
import { addPost } from "../lib/posts.ts";
export default function Page() {
return (
<main>
<ul>
{db.posts.list().map((post) => (
<li>{post.title}</li>
))}
</ul>
<form action={addPost}>
<input name="title" />
<button>投稿する</button>
</form>
</main>
);
}sobria は、フォームの method を post に、enctype を multipart/form-data にします。HTML の action 属性は出力しないので、ブラウザは、クエリを含めた現在のページの URL に送信します。sobria は、name が _sobria_fn で、値が送信先のサーバー関数の ID である hidden input を、フォームに出力します。<input type="file"> のファイルは、handler で File として受け取れます。
JS が無いときの送信
JS が無いときは、ブラウザの通常の送信になります。handler の結果に応じて、次のレスポンスを返します。
| handler の結果 | レスポンス |
|---|---|
値を返さなかった(undefined) | 送信したページへの 303 のリダイレクト |
undefined 以外の値を返した | 送信したページを描画したレスポンス |
redirect() を投げた | その URL への 303 のリダイレクト |
Response を返したか、HTTPException を投げた | その Response |
notFound() を投げた | 404 |
input のスキーマの検証に失敗した | 422 の { issues }。ブラウザは JSON を表示します |
| それ以外の例外を投げた | 送信したページに最も近い error.tsx を 500 で表示 |
handler が undefined 以外の値を返したときと、それ以外の例外を投げたときは、送信したページへの GET と同じく、そのページのディレクトリまでのすべての middleware.ts を実行してから描画します。例外を投げたときは、ページの代わりに、そのページに最も近い error.tsx を描画します。middleware.ts がページの代わりにレスポンスを返したとき(redirect() と notFound() を投げたときを含む)は、そのページへの GET と同じレスポンスを返します。ただし、307 と 308 のリダイレクトは、フォームの本文が移動先に再送信されないように、ステータスを 303 に変換します。
handler が c.header() や setCookie() で設定したヘッダーは、handler が値を返した後に描画したページのレスポンスにも付与します。
handler が例外を投げたときに error.tsx が無ければ、500 を返します。本番のサーバーは Internal Server Error のテキストを返し、開発サーバーはエラーの画面を返します。
JS があるときの送信
ページに要素が 1 つでもあれば、sobria はフォームを fetch で送信します。送信する内容とサーバー関数の実行は、JS が無いときと同じです。
- 送信の結果、移動先が現在のページなら、ページを描き直した結果を反映してから、フォームを初期値に戻します。フォームの部品の
form.onResetは、描き直した後の props を読みます。URL とスクロールの位置は変化せず、フォームの部品でない要素の状態も保持されます - 移動先が別のページなら、そのページに移動します
- handler が
undefined以外の値を返したときは、フォームを初期値に戻さずに、送信したページを描き直した結果を反映します - handler が例外を投げて 500 になったときは、
error.tsxがあれば、URL を変更せずに、ページをerror.tsxに差し替えます - handler が例外を投げた後で、送信したページの
middleware.tsがリダイレクトしたときは、最初の 2 つの箇条と同じく、移動先に応じて反映します。notFound()を投げたときは、not-found.tsxがあれば、URL を変更せずに、ページをnot-found.tsxに差し替えます。使用するnot-found.tsxは、そのページへの GET で使用するものと同じです - そのほかのレスポンス(
error.tsxが無いときの 500、スキーマの検証に失敗した 422、handler やmiddleware.tsが返した独自の Response など)を受け取ったときは、ページを変更せずに、レスポンスをコンソールに記録します
要素の無いページでは、JS が無いときと同じく、ブラウザの通常の送信になります。
送信中は、フォームに aria-busy="true" が付与されます。送信中のフォームを再び送信しても、2 回目の送信は無視します。送信中の見た目は CSS で指定します。例えば、送信中のボタンを薄く表示するには、次のように記述します。
form[aria-busy="true"] button {
opacity: 0.5;
}onSubmit は、sobria の送信より先に実行します。onSubmit の中で e.preventDefault() を呼び出すと、sobria は送信しません。
FormData の名前の構造
フィールドの名前に . や [] を含めると、input はネストしたオブジェクトや配列になります。
| 名前 | input の構造 |
|---|---|
address.city | オブジェクト |
items[0].name | オブジェクトの配列 |
tags[] | フィールドごとに要素を追加する配列 |
| 同じ名前の繰り返し | 配列 |
値は文字列のまま渡します。数として扱う値は、スキーマで変換します。
// src/lib/orders.ts
import { defineServerFn } from "sobria/server";
import { z } from "zod";
const orderSchema = z.object({
address: z.object({ city: z.string(), zip: z.string() }),
items: z.array(z.object({ name: z.string(), count: z.coerce.number().int().min(1) })),
tags: z.array(z.string()).default([]),
});
export const placeOrder = defineServerFn({ input: orderSchema })(async (c, { input }) => {
console.log(input.items[0]?.count);
});// src/routes/order/page.tsx
import { placeOrder } from "../../lib/orders.ts";
export default function Page() {
return (
<form action={placeOrder}>
<input name="address.city" />
<input name="address.zip" />
<input name="items[0].name" />
<input name="items[0].count" type="number" />
<input name="items[1].name" />
<input name="items[1].count" type="number" />
<label>
<input type="checkbox" name="tags[]" value="gift" /> 贈り物
</label>
<label>
<input type="checkbox" name="tags[]" value="express" /> 速達
</label>
<button>注文する</button>
</form>
);
}チェックボックスを 1 つも選択しないと、tags のフィールドは送信されません。
名前を . と [] で区切った部分のどれかが __proto__、constructor、prototype のフィールドは破棄します。
添字が、それより前のフィールドで作成した配列の長さより大きいフィールドは破棄します。例えば、items[0] が無いフォームの items[1].name は破棄します。1 つの名前には、tags と tags[] のどちらか 1 つの形だけを使用してください。
ブラウザは、送信する値の改行を CRLF(\r\n)に変換します。<textarea> の値と、フォームの部品になる要素の値も変換するので、input の文字列の改行は CRLF になります。LF の改行の値が必要なときは、handler の中で input の値の CRLF を LF に置換します。
失敗を表示する
input のスキーマの検証に失敗したときの 422 は、利用者に表示する画面になりません。利用者に表示する検証のエラーは、handler の中で検証して、値として返します。フォームの送信で handler が undefined 以外の値を返すと、sobria はリダイレクトせずに、送信したページを描画します。ページは、getServerFnResult で handler が返した値を受け取ります。
// src/lib/users.ts
import { defineServerFn, redirect } from "sobria/server";
import { z } from "zod";
const taken = new Set(["admin"]);
export const signup = defineServerFn({
input: z.object({ name: z.string() }),
})(async (_c, { input }) => {
if (input.name === "") return { errors: { name: "名前を入力してください" }, values: input };
if (taken.has(input.name)) {
return { errors: { name: "この名前は使用されています" }, values: input };
}
taken.add(input.name);
throw redirect("/");
});// src/routes/signup/page.tsx
import { getServerFnResult } from "sobria/server";
import { signup } from "../../lib/users.ts";
export default function Page() {
const result = getServerFnResult(signup);
return (
<form action={signup}>
<label for="name">名前</label>
<input
id="name"
name="name"
value={result?.values.name ?? ""}
aria-invalid={result?.errors.name !== undefined}
/>
{result?.errors.name && <p>{result.errors.name}</p>}
<button>登録する</button>
</form>
);
}getServerFnResult(fn)は、ページを描画するきっかけになったリクエストでfnが値を返していなければ、undefinedを返します。getServerFnResultでは、別のサーバー関数が返した値は読めません- 値を返す代わりに別のページへ移動するには、handler で
redirect()を投げます。handler が何も返さないときは、送信したページにリダイレクトします - JS があるときは、sobria はフォームを初期値に戻さずに、描き直したページを反映します。JS が無いときは、送信したページを POST のレスポンスとして表示するので、入力欄の値は、ページが出力した値になります。どちらの場合も同じ表示にするには、上の例のように、handler が返した入力の値をページが入力欄に渡します
- JS が無いときに、値を返した後の画面で再読み込みすると、ブラウザは送信をやり直すかを確認します
- 同じサーバー関数を使うフォームが 1 つのページに複数あるときは、どのフォームの結果かを区別できる値(id など)を、handler の戻り値に含めます
- 要素の描画の中では
getServerFnResultを呼べません。ページが props で要素に値を渡します
フォームの部品になる要素
要素の関数が ctx の form を使用すると、その要素は <input> と同じくフォームの部品になります。<form> の中に配置すると、name 属性に指定した名前で値を送信します。
// src/elements/rating.tsx
import { signal, type Ctx } from "sobria";
type Props = { required?: boolean; max?: number };
export default function Rating(props: Props, { form, internals }: Ctx) {
const [value, setValue] = signal(0);
internals.role = "radiogroup";
form.value(() => String(value()));
form.validity(() =>
props.required && value() === 0
? { valueMissing: true, message: "評価を選んでください" }
: null,
);
form.onReset(() => setValue(0));
form.onRestore((state) => setValue(Number(state) || 0));
const stars = Array.from({ length: props.max ?? 5 }, (_, i) => i + 1);
return (
<div>
{stars.map((n) => (
<button
type="button"
role="radio"
aria-checked={value() === n}
aria-label={`${n} / ${stars.length}`}
disabled={form.disabled()}
onClick={() => setValue(n)}
>
{value() >= n ? "★" : "☆"}
</button>
))}
</div>
);
}// src/routes/review/page.tsx
import Rating from "../../elements/rating.tsx";
import { submitReview } from "../../lib/reviews.ts";
export default function Page() {
return (
<form action={submitReview}>
<label for="score">評価</label>
<Rating id="score" name="score" required />
<button>送信する</button>
</form>
);
}| 関数 | 振る舞い |
|---|---|
value(fn) | fn が返した文字列か File を、要素の name で送信します。FormData を返すと、要素の name を使用せずに、エントリーごとにエントリーの名前で送信します。null と undefined を返すと、値を送信しません |
validity(fn) | fn が null を返すと有効にし、オブジェクトを返すと、message の文言で無効にします。返すオブジェクトに valueMissing: true のような制約を含めると、無効の種類を指定できます |
onReset(fn) | フォームが初期値に戻されたときに、fn を呼び出します |
onRestore(fn) | 戻る操作やオートフィルでブラウザが値を復元したときに、復元した値を渡して fn を呼び出します |
disabled() | 要素の disabled か、外側の <fieldset disabled> で無効になっていれば true を返します |
value と validity に渡した関数は、読み取った Signal が変化するたびに実行し直します。validity で無効にした要素があると、ブラウザは送信を中止して、message の文言を表示します。internals は、要素の ElementInternals です。
フォームの部品になった要素は、Props の型に記述しなくても name と disabled を受け付けます。<label for> でラベルを関連付けたり、checkValidity() を呼び出したりする操作も、<input> と同じく使用できます。
要素が起動する前と JS が無いときも、要素の値は送信されます。ただし、送信される値はサーバーで描画した時点の値で、validity の検証も実行しません。値は、サーバー関数の input のスキーマでも検証してください。File と、FormData の File のエントリーは、JS が無いときと要素が起動する前には送信されません。
要素が起動する前に、利用者が要素の Shadow DOM の中の <input> に入力した値は、送信されません。送信されるのは、サーバーで描画した時点の form.value の値です。JS が無いときも利用者の入力を送信する要素は、ネイティブの <input> を要素の子に配置し、要素の Shadow DOM の <slot> で表示します。
戻る操作でページに戻ったときに、Chromium は、File の値と、File のエントリーを持つ FormData の値を復元せず、その要素の onRestore の関数を呼び出しません。
form は、要素の関数の第 2 引数の分割代入({ form })、ctx.form、const { form } = ctx のどれかの形で参照します。ctx を別の関数に渡して、その中で form を参照すると、ビルドのエラーになります。
Vite プラグインを使用せずに defineElement で要素を登録するときは、3 つ目の引数の ElementOptions に form: true を指定します。defineElement の引数は API のリファレンス を参照してください。