最近、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 (
・・・
タグ: SolidJS, SolidStart