remix-toastのjsonWithSuccessのようなサーバー側でトースト管理する方法がNextjs(conform)にあればな・・
開発環境
- next 14.2.6
- @conform-to/react 1.1.5
- @conform-to/zod 1.1.5
前提
NextjsはAppRouterを利用しています。
本題
お仕事では未だServerActionsを利用する機会はないですが、個人的にはよく利用しています。
ServerActionsを利用する際は、ReactHookFormに近い感覚で実装できることと、ServerActionsとの相性を考慮するとconformが自分的には第一選択肢です。
フォームを実装するとき、以下の2点がよくある実装かなと思います。
- ユーザーの入力に応じてエラーメッセージをインタラクティブに出す
- バリデーションをパスした後にAPIリクエストを行って、サーバーとのやり取りから成功、失敗に応じてトーストを出す
今回は上記の2に焦点を当てて、ServerActions x conform を利用したフォームでトーストを出す一例を紹介します。
ただ、タイトルにある通り、remix-toastのjsonWithSuccessのようなサーバー側でトースト管理する方法があればもっとシンプルに実装できるはずです。
そんな、個人的には未完成な実装ですが参考にしてもらえると嬉しいです。
実装例
(今回はSkillというデータを新規作成するフォームで話を進めます)
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useForm, getFormProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState, useFormStatus } from 'react-dom';
import { Field, FieldErrors } from '@/components/form/field';
import { FormInput } from '@/components/form/form-input';
import { FormSubmittedToast } from '@/components/form/form-submitted-toast';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { toast } from '@/hooks/use-toast';
import { createSkill } from '../../actions/create';
import { createSKillSchema } from '../../schemas/create';
const SubmitButton = () => {
const { pending } = useFormStatus();
return (
<Button
type="submit"
form="create-skill"
disabled={pending}
className="w-full"
>
新規登録
</Button>
);
};
export const CreateSkillForm = () => {
const [lastResult, action] = useFormState(createSkill, undefined);
const [form, fields] = useForm({
id: 'create-skill',
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: createSKillSchema });
},
shouldValidate: 'onInput',
});
const router = useRouter();
const onSuccess = useCallback(() => {
toast({ description: 'スキルを登録しました' });
router.back();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onError = useCallback((errorMessage?: string) => {
toast({
variant: 'destructive',
description: errorMessage || 'スキルを登録できませんでした',
});
}, []);
return (
<form
className="grid gap-4"
{...getFormProps(form)}
action={action}
noValidate
>
<Field>
<Label htmlFor={fields.name.id}>名称</Label>
<FormInput meta={fields.name} type="text" />
<FieldErrors errors={fields.name.errors} />
</Field>
<Field>
<Label htmlFor={fields.url.id}>URL</Label>
<FormInput meta={fields.url} type="text" />
<FieldErrors errors={fields.url.errors} />
</Field>
<SubmitButton />
<FormSubmittedToast
lastResult={lastResult}
onSuccess={onSuccess}
onError={onError}
/>
</form>
);
};
このフォームコンポーネントでは、useFormStateを利用し、actionの実行結果を返り値lastResultで受け取っています。
そのlastResultを<FormSubmittedToast />というコンポーネントに渡し、状態に応じてトーストを出す処理を行っています。
FormSubmittedToastの実装は以下になります。
import { useEffect } from 'react';
import type { SubmissionResult } from '@conform-to/react';
type FormSubmittedToastProps = {
lastResult?: SubmissionResult<string[]> | null;
onSuccess?: () => void;
onError?: (errorMessage?: string) => void;
};
const FormSubmittedToast = ({
lastResult,
onSuccess,
onError,
}: FormSubmittedToastProps) => {
useEffect(() => {
if (!lastResult) return;
if (lastResult.status === 'success' && onSuccess) {
onSuccess();
}
if (lastResult.status === 'error' && onError) {
const errorMessages = lastResult.error && lastResult.error[''];
onError(errorMessages ? errorMessages[0] : undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastResult]);
return null;
};
export { FormSubmittedToast };
lastResult.errorの構造が特殊なので、メッセージの取り出し方が複雑になっていますが、やりたいことはlastResultのstatusを見て、メッセージを出しているということです。
最後に、action(createSkill)は以下のような実装になっています。
'use server';
import { parseWithZod } from '@conform-to/zod';
import { auth } from '@/lib/next-auth/auth';
import { createSKillSchema } from '../../schemas/create';
export async function createSkill(_: unknown, formData: FormData) {
const submission = parseWithZod(formData, {
schema: createSKillSchema,
});
if (submission.status !== 'success') {
return submission.reply();
}
const session = await auth();
if (!session) {
return submission.reply({
formErrors: ['認証エラーです'],
});
}
try {
// ...何かしらの登録処理
return submission.reply();
} catch (error) {
throw error;
}
}
実装例として、認証でエラーが発生した際にエラーメッセージをクライアントに返すようにしています。
// ↓この処理
return submission.reply({
formErrors: ['認証エラーです'],
});
また、成功した場合はメッセージなしでそのままreply()するように実装しています。
parseWithZodを利用するととてもシンプルに実装できます。
振り返り
ここまで読んでいただいた方ならお気づきだと思いますが、ただ、愚直にクライアント側でトースト出してページ遷移しているだけでは?と思われたはずです。
その通りです。特別なことは何もしていないです。
もし、トーストを出さない方針なら、今回の例で言うとaction(createSkill)は以下のような実装にすればフォームコンポーネントの実装は<FormSubmittedToast />が不要になるので、もっとシンプルになります。
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { parseWithZod } from '@conform-to/zod';
import { auth } from '@/lib/next-auth/auth';
import { createSKillSchema } from '../../schemas/create';
export async function createSkill(_: unknown, formData: FormData) {
const submission = parseWithZod(formData, {
schema: createSKillSchema,
});
if (submission.status !== 'success') {
return submission.reply();
}
const session = await auth();
if (!session) {
return submission.reply({
formErrors: ['認証エラーです'],
});
}
try {
// ...何かしらの登録処理
// return submission.reply();
revalidatePath('/api/skills'); // キャッシュ破棄して
redirect('/skills') // リダイレクト
} catch (error) {
throw error;
}
}
このシンプルな実装に対して、タイトルにあるjsonWithSuccessのようにサーバーで実行できるトースト管理ができれば、どれだけスッキリするか・・
そんな思いで、「現状こうしています」という記事です。
さいごに
SeverActions環境下でのトースト管理の良い方法があれば教えてください・・