sobria
ページの一覧

フォーム

サーバー関数を送信先にする

<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 のリファレンス を参照してください。