logo

remix-toastのjsonWithSuccessのようなサーバー側でトースト管理する方法がNextjs(conform)にあればな・・

2024-09-13
3 months ago

開発環境

  • next 14.2.6
  • @conform-to/react 1.1.5
  • @conform-to/zod 1.1.5

前提

NextjsAppRouterを利用しています。

本題

お仕事では未だServerActionsを利用する機会はないですが、個人的にはよく利用しています。

ServerActionsを利用する際は、ReactHookFormに近い感覚で実装できることと、ServerActionsとの相性を考慮するとconformが自分的には第一選択肢です。


フォームを実装するとき、以下の2点がよくある実装かなと思います。

  1. ユーザーの入力に応じてエラーメッセージをインタラクティブに出す
  2. バリデーションをパスした後にAPIリクエストを行って、サーバーとのやり取りから成功、失敗に応じてトーストを出す

今回は上記の2に焦点を当てて、ServerActions x conform を利用したフォームでトーストを出す一例を紹介します。

ただ、タイトルにある通り、remix-toastjsonWithSuccessのようなサーバー側でトースト管理する方法があればもっとシンプルに実装できるはずです。

そんな、個人的には未完成な実装ですが参考にしてもらえると嬉しいです。

実装例

(今回は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>
  );
};
create-skill-form.tsx

このフォームコンポーネントでは、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 };
form-submitted-toast.tsx

lastResult.errorの構造が特殊なので、メッセージの取り出し方が複雑になっていますが、やりたいことはlastResultstatusを見て、メッセージを出しているということです。

最後に、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;
  }
}
actions/create/index.ts

実装例として、認証でエラーが発生した際にエラーメッセージをクライアントに返すようにしています。

// ↓この処理
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;
  }
}
actions/create/index.ts

このシンプルな実装に対して、タイトルにあるjsonWithSuccessのようにサーバーで実行できるトースト管理ができれば、どれだけスッキリするか・・

そんな思いで、「現状こうしています」という記事です。

さいごに

SeverActions環境下でのトースト管理の良い方法があれば教えてください・・

参照