user icon

SolidStartのフォーム送信

最近、SolidJSとSolidStartの勉強を始めました。
SolidJSはReactライクなフロントエンドフレームワークですが、仮想DOMを使わない事により軽量・高パフォーマンスを実現しています。
高パフォーマンスな他の理由として、コンポーネント関数は一度しか実行されず、後は該当箇所だけをリアクティブに更新する、というのがあります。

SolidStartは、React.jsに対するNext.jsと同じで、ルーティングやSSR/SSGが可能になります。2023年2月現在、バージョン0.2.20でまだβ版です。

SolidStartのGetting Startedを読んでてこれは便利そうだなと思ったのが、クライアントとサーバ間のデータのやり取りで使われるActionsです。

フォーム送信の場合、以下のような感じです。

export default function MyComponent() {
  const [sending, { Form }] = createServerAction$(async (formData: FormData) => {
    console.log(formData);
     return "保存しました。";
  });

  return (
    <>
      <Show when={sending.error}><div> {sending.error.message }</div></Show>
      <Show when={sending.result}><div>{sending.result}</div></Show>
      <Show when={sending.pending}><div>loading・・</div></Show> 
      <Form>
        <label for="username">Username:</label>
        <input type="text" name="username" />
        <input type="submit" value="保存" />
      </Form>
    </>
  );
}

保存ボタンを押すと、multipart/form-data(←たぶんファイルも送れる)でPOST送信され、サーバ側でcreateServerAction$関数が引数FormDataオブジェクトで実行され、返り値をクライアントに返します。クライアント側では、result/error/pendingをリアクティブに更新します。
BaaS (Backend As A Service) のデータベースを使ってて、クライアントから直接データベースにアクセスしてほしくない時などに使えます。
なお、createServerAction$ではなくcreateRouteActionを使うと、サーバ側ではなくクライアント側で実行されます。非同期処理する時に使います。

この時にバリデーションをどう実装すべきでしょうか。
サーバ側でバリデーションし、エラーをクライアントに投げる事ができますが、クライアント側でもバリデーションしたいです。
SolidJS本家のサイトにバリデーションのサンプルがありますが、DOM APIを使ってバリデーションしてます。確かにこれが最も軽量な実装方法ですが、手間です。
上記サンプルのソースで実装してみました。
しかし問題が発生しました。SolidStartのActionsとの連携がうまくいきません。

<Form use:formSubmit={fn}>

上記の形だと、createServerAction$の場合はformSubmit関数が実行されませんでした。createRouteActionだと、formSubmitが実行されますが。
結局以下のようにしました。

declare module "solid-js" {
  namespace JSX {
    interface Directives {
      validate: any;
    }
  }
}

export default function MyComponent() {
  const { validate, formSubmit, errors } = useForm({
    errorClass: "error-input"
  });

  const [sending, { Form }] = createServerAction$<FormData, string>(async (formData) => {
    ・・・・
  });

  return (
    <>
      <Show when={sending.error}><div> {sending.error.message }</div></Show>
      <Show when={sending.result}><div>{sending.result}</div></Show>
      <Show when={sending.pending}><div>loading・・</div></Show>
      <Form onSubmit={formSubmit} novalidate>
        {errors.username && <div>{errors.username}</div>}
        <label for="username">Username:</label>
        <input
          type="text"
          name="username"
          required
          use:validate
          />
        <input type="submit" value="保存" />
      </Form>
    </>
  );
}

formSubmit関数も上記変更に合わせ、以下のようにしました。

  const formSubmit = (e: any) => {
    let errored = false;
    for (const k in fields) {
      const field = fields[k];
      checkValid(field, setErrors, errorClass)();
      if (!errored && field.element.validationMessage) {
        field.element.focus();
        errored = true;
      }
    }
    if (errored) e.preventDefault();
  };

でもやっぱりもっと簡単に実装したい、そしてできればzod等を使って実装し、サーバ側でもクライアントと同じロジックでバリデーションしたいです。
と思って探したらありました。
FelteライブラリがSolidJSに対応していました。
ここを参考に作ってみました。
が、上記と同様にSolidStartのActionsとの連携で問題が発生しました。

<Form ref={form}>

この形だと、SolidStartのActionsが正しく動作しません。

また、

<Form use:form>

この形だと、保存ボタンを押した時にバリデーションが働きません。

結局、以下のようにしました。

import "tippy.js/dist/tippy.css";
import { createForm } from "@felte/solid";
import { validator } from "@felte/validator-zod";
import {  Show } from "solid-js";
import { FormError } from "solid-start";
import { createServerAction$ } from "solid-start/server";
import reporter from "@felte/reporter-tippy";
import { z } from "zod";

const schema = z.object({
  username: z.string().min(1, { message: '必須です。' }),
  email: z.string().min(1, { message: '必須です。' }).email({ message: 'メールアドレスに誤りがあります。' }),
});

export default function MyComponent() {
  const { form } = createForm<z.infer<typeof schema>>({
    initialValues: {
      username: '',
      email: '',
    },
    extend: [validator({ schema }), reporter()],
    onSubmit: (values, context) => {
      enroll(new FormData((document.getElementById('form') as HTMLFormElement)));
    },
  });


  const [sending, enroll] = createServerAction$<FormData, string>(async (formData) => {
    const formDataObj: any = {};
    formData.forEach((value, key) => (formDataObj[key] = value));
    // もしformDataObjがschemaに一致しなかったらエラーが投げられる。
    const user = schema.parse(formDataObj);
    if (user.username === "admin") {
      throw new FormError("adminは使えません。");
    } else {
      return "保存しました。";
    }
  });
  return (
    <>
      <Show when={sending.error}><div> {sending.error.message }</div></Show>
      <Show when={sending.result}><div>{sending.result}</div></Show>
      <Show when={sending.pending}><div>loading・・</div></Show>
      <form ref={form} id="form">
        <label for="username">username:</label>
        <input id="username" name="username" type="text" />
        <label for="email">Email:</label>
        <input id="email" name="email" type="email" />
        <input type="submit" value="保存" />
      </form>
    </>
  );
}

キモは、<Form>は使わず、以下の箇所でサーバにFormDataを送信するところです。

    onSubmit: (values, context) => {
      enroll(new FormData((document.getElementById('form') as HTMLFormElement)));
    },

これでクライアント・サーバとも、同じロジックでバリデーションされるようになりました。
ただこうなると、FormDataを送信するのではなく、values:{username: string;email: string;}を送信するのが正しい気がします。
ということで、以下のようになります。
これでapplication/jsonでPOST送信され、createServerAction$はJSONがデコードされたオブジェクトを受け取るようになりました。

export default function MyComponent() {
  const { form } = createForm<z.infer<typeof schema>>({
    initialValues: {
      username: '',
      email: '',
    },
    extend: [validator({ schema }), reporter()],
    onSubmit: (values) => {
      enroll(values); // {username: string;email: string;}を送信
    },
  });

  const [sending, enroll] = createServerAction$<any, string>(async (v) => {
    // もしvがschemaに一致しなかったらエラーが投げられる。
    const user = schema.parse(v);
    if (user.username === "admin") {
      throw new FormError("adminは使えません。");
    } else {
      return "保存しました。";
    }
  });
  return (
・・・
Facebooktwitterlinkedintumblrmail

Tags: ,

名前
E-mail
URL
コメント

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)