確認モーダルのあるフォームはどれにtype="submit"を割り当てる? Remix + Conformの実装してみる
背景(どんなフォームを実装したいか)
みなさん、確認モーダルがあるフォームを作りたいと思ったことはありますか?
私はあります。例えば以下のようなものです。
- フィールドに入力していく
- 入力完了ボタンをおす
- フィールドに不備がない場合は、確認ダイアログが表示される。
フィールドに不備がある場合は、確認ダイアログが表示されずフィールドしたに不備の内容を出す。 - 確認ダイアログの決定ボタンを押すとフォームに入力した内容がSubmitされる
今回はこのフローにおいて、type="submit"
のボタンをどこにするかを検討したいと思います。
上記フローの実際の動きはこちら
デモ1
デモ2
みなさんは どのボタンにtype="submit"を割り当てるか? を悩んだことはありませんか?
今回はこの悩みについて述べていこうと思います!
そして、最後にRemix+Conformでの実装を見ていきましょう。
どのボタンにtype="submit"を割り当てるか?
みなさん、このフローだとどこに<button type="submit">
をおきますか?
入力完了ボタンですか?それとも確認モーダルの決定ボタンですか?
私は確認モーダル内の決定ボタンにtype="submit"
を配置して、フォーム内容を送信するのがいいと思っています。
フォームのトランザクションとして捉える。
フォーム内のモーダル作成に悩んでいた時に、とても納得感のあったZennがあります。
formはトランザクションに近いと考えています。ここで言うトランザクションはSQLのトランザクションと同様に、入力を保持しcommitかrollbackされるまで反映されない一連の処理を指します。
https://zenn.dev/akfm/articles/react-hook-form-of-modal
そうです、フォームを一つのトランザクションとして考える見方です。
この見方からすると、確認モーダルをもつフォームの場合、確認モーダルの決定ボタンを押すことこそがトランザクションでのCommitに当たると考えます。
このパターンだとすごくスッキリとフォームを表すことができているし、実際に実装はしやすいです。
逆に入力完了ボタンにtype=submit
を持たせる方法を考えてみましょう。
動作の流れは以下のようになると思います。
入力完了ボタンをクリック → Submitのイベントを中断 → バリデーション → 確認モーダル → フォーム内容を送信
これでも動かすことができるのですが、以下の理由から筆者は良くないと考えています。
-
Submitする!ってボタンを押しているのにSubmitされない気持ち悪さ。
Submitする!ってボタンは言ってるのに、実際にはフォーム内容はSubmitされないというコードとしてはすごく予想ができない動きになっています。
これは関数の名前どうするか議論にも似ている気がします。eatSalad
という関数の中で、サラダを食べる前に水を飲む処理が書かれているようなそんな気持ち悪さが感じられます。 -
コードが複雑化する可能性がある。
「イベントを中断する+Submitする処理を実装 」を書かなければいけなくなり、form自身のSubmit機能、つまりみんなが知りうるブラウザ標準機能を使わないことになります。
ブラウザの標準機能を活用した方がコードはシンプルに保たれるはずなので、あまり良くないと思います。
共通化して使う時は確認モーダルとしてコンポーネント化すると良いと思います。
ConfirmModalみたいな共通コンポーネントを作り、「ComfirmModalの決定ボタンはtype="submit"
をもっている」とチームで共通認識を持っておけば大丈夫だと思います。あとは基本レビューでチェックしておきましょう。
いろいろ書きましたが、正直好き嫌いが分かれる部分なのかもしれないです。(悪い点は捻り出して出てきたくらいなので)
そもそも確認モーダルって必要?
ここまでいろいろ書きましたがそもそも確認モーダルって必要でしょうか?
確認モーダルが必要かどうかは時と場合によると思います。ただ必要でない場合の方が多いのではないでしょうか?(個人の感想)
基本的にはない方がアプリのフォームの操作体験は良く、むしろ確認モーダルが出てきて操作を中断されるのはやっぱりストレス感じますよね。私は多くの場合好きではありません。
しかし、一定必要な場面はあると思います。以下に自分なりに持っている「確認モーダルを入れる指標」を記載します。
- 次の操作に入ると、その値を変更することができない、かつその操作が重要である時。 → 確認モーダルあり
- それ以外 → 確認モーダルなし
私は変更できても、値自体がそこまで重要度がかなり高いものでなければ確認モーダルを入れる必要はないと思っています。
Remix + Conformで実装する
以前作った無駄なものチェッカーを例に見ていこうと思います。
この画面は商品入力画面です。
この画面のコードをスタイルを全部なしにしてコードをみていこうと思います。
クライアントサイド
まずはクライアント側の実装。以下のフローが実行されるようにしている。
- フィールドに入力していく
- 入力完了ボタンをおす
- フィールドに不備がない場合は、確認ダイアログが表示される。
フィールドに不備がある場合は、確認ダイアログが表示されずフィールドしたに不備の内容を出す。- 確認ダイアログの決定ボタンを押すとフォームに入力した内容がSubmitされる
基本Submit時にバリデーションが実行されるのですが、今回のフローでは確認ダイアログを表示する前に、バリデーションを実行したいです。
なので、入力完了ボタンをクリックした時に、バリデーションが実行されるようになっています。
さらに確認モーダルから決定ボタンでSubmitされる時に検証+サーバーサイドでも検証しているので安全ですね。
あと本筋ではなく単なるお気持ちなのですが、今回のようなケースではSubmit待ちみたいなものは確認モーダル側に表示するのがいいと思っています。
シンプルなUIでできることが直感的にわかることが大事だと思っています。なので確認モーダルにSubmit待ちの表示を組み込むことで、ユーザーの画面変化のプロセスが減り、操作の負担が軽減されると思っています。
また、UIの挙動が制限されることで、ユーザーが予期せぬ動作をするリスクも減らせるんじゃないでしょうか?
export default function Index() {
// RemixではuseNavigationを用いて、actionの実行を判断できる。
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/items/new";
// Conform使う
const [form, { item }] = useForm({
id: "item-form",
// submit時にバリデーションを実行する。
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
constraint: getZodConstraint(schema),
shouldRevalidate: "onBlur",
});
// dialogを開いたり閉じたりの実装
const dialogRef = useRef<HTMLDialogElement>(null);
const openDialog = () => {
if (dialogRef.current) {
dialogRef.current.showModal();
}
};
const closeDialog = () => {
if (dialogRef.current) {
dialogRef.current.close();
}
};
return (
<Form method="post" {...getFormProps(form)}>
<label htmlFor={item.id}>検討している商品名を教えて!</label>
<input
name="item"
type="text"
id={item.id}
placeholder="検討中の商品名を入力しーや"
required={item.required}
onBlur={(event) => form.validate({ name: event.target.name })}
aria-invalid={item.errors ? true : undefined}
aria-describedby={item.errors ? item.errorId : undefined}
/>
<div role="alert" id={item.errorId}>
{item.errors}
</div>
<button
type="button"
onClick={async () => {
// 確認ダイアログを表示する前に、バリデーションを実行したいのでここで実行する。
const validationResult = schema.safeParse(form.value);
if (validationResult.success) {
openDialog();
} else {
form.validate(item);
}
}}
>
入力完了
</button>
<dialog ref={dialogRef} aria-labelledby="confirm-dialog">
{isSubmitting ? (
" Please Wait "
) : (
<>
<div>
<div>
<p id="confirm-dialog">これでええか?</p>
<strong>{form.value?.item}</strong>
</div>
</div>
<div>
<button type="button" onClick={closeDialog}>
ちゃう
</button>
<button type="submit">決定</button>
</div>
</>
)}
</dialog>
</Form>
);
}
サーバーサイド
Conform使うとクライアントサイドでも検証と同じように検証できるので嬉しいです。
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
// サーバーサイドでのバリデーションを実行する。
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json({ success: false, message: "error!", submission });
}
const item = formData.get("item");
const id = uuidv4();
console.log(id, item);
return redirect(`/items/${id}/assessment`);
};
バリデーション
ただのzodのコードです。zodいいね👍👍
const schema = z.object({
item: z.string({ required_error: "入力されてへんで、入力してや!" }),
});
実装まとめ
const schema = z.object({
item: z.string({ required_error: "入力されてへんで、入力してや!" }),
});
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
// サーバーサイドでのバリデーションを実行する。
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json({ success: false, message: "error!", submission });
}
const item = formData.get("item");
const id = uuidv4();
console.log(id, item);
return redirect(`/items/${id}/assessment`);
};
export default function Index() {
const navigation = useNavigation();
const isSubmitting = navigation.formAction === "/items/new2";
const [form, { item }] = useForm({
id: "item-form",
// submit時にバリデーションを実行する。
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
constraint: getZodConstraint(schema),
shouldRevalidate: "onBlur",
});
const dialogRef = useRef<HTMLDialogElement>(null);
const openDialog = () => {
if (dialogRef.current) {
dialogRef.current.showModal();
}
};
const closeDialog = () => {
if (dialogRef.current) {
dialogRef.current.close();
}
};
return (
<Form method="post" {...getFormProps(form)}>
<label htmlFor={item.id}>検討している商品名を教えて!</label>
<input
name="item"
type="text"
id={item.id}
placeholder="検討中の商品名を入力しーや"
required={item.required}
onBlur={(event) => form.validate({ name: event.target.name })}
aria-invalid={item.errors ? true : undefined}
aria-describedby={item.errors ? item.errorId : undefined}
/>
<div role="alert" id={item.errorId}>
{item.errors}
</div>
<button
type="button"
onClick={async () => {
// 確認ダイアログを表示する前に、バリデーションを実行したいのでここで実行する。
const validationResult = schema.safeParse(form.value);
if (validationResult.success) {
openDialog();
} else {
form.validate(item);
}
}}
>
入力完了
</button>
<dialog ref={dialogRef} aria-labelledby="confirm-dialog">
{isSubmitting ? (
" Please Wait "
) : (
<>
<div>
<div>
<p id="confirm-dialog">これでええか?</p>
<strong>{form.value?.item}</strong>
</div>
</div>
<div>
<button type="button" onClick={closeDialog}>
ちゃう
</button>
<button type="submit">決定</button>
</div>
</>
)}
</dialog>
</Form>
);
}
最後に
やっぱりまだフォームで悩むことは多いので、全然筋が良くなければおしえていただきたいです!
たまにフォームの実装つらくなります。
ビジネス的にはかなり重要になるので、どうにか自分なりのベストプラクティスを作っていきたいです。
あとXやってるのでぜひフォローお願いします。
@hudebakonosoto
Discussion