logo

ServerActions x conform でのトースト実装例

2025-02-26
a month ago

開発環境

  • next 15.0.2
  • @conform-to/react 1.2.2
  • @conform-to/zod 1.2.2

前提

こちら↓の記事の続きの投稿になります。

ご一読いただいた後に本記事を読んでいただくことを前提に進めます。

本題

前回の記事ではFormSubmittedToastというコンポーネントを実装して、サーバー側へのリクエストが成功した後に、クライアント側でトーストを実装する処理を実装していました。

上記の実装の課題としてコード量が多く、毎回利用するFormコンポーネントでonSuccessonError関数を定義する必要がありました。

Formコンポーネントが多くの責務を負っていたので、煩雑さがありました。

これを解決するべく、今回はHooksを実装することでより責務を分け、スッキリした実装に改良してみました。

早速、コードを紹介していきます。

まず、前回の記事で紹介した元々のコードはこちら。

'use client';

import { useActionState, useCallback } from 'react';

import { useRouter } from 'next/navigation';

import { useForm, getFormProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';

import { Field, FieldErrors } from '@/components/form/field';
import { FormInput } from '@/components/form/form-input';
import { FormMultiSelect } from '@/components/form/form-multi-select';
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 { SelectOptions } from '@/types';

import { createSkill } from '../../actions/create';
import { createSKillSchema } from '../../schemas/create';

type CreateSkillFormProps = {
  projectOptions?: SelectOptions;
};

export const CreateSkillForm = ({
  projectOptions = [],
}: CreateSkillFormProps) => {
  const [lastResult, action, isPending] = useActionState(
    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 px-1"
      {...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>
      <Field>
        <Label htmlFor={fields.projectIds.id}>プロジェクト</Label>
        <FormMultiSelect meta={fields.projectIds} options={projectOptions} />
        <FieldErrors errors={fields.projectIds.errors} />
      </Field>
      <Button
        type="submit"
        form="create-skill"
        disabled={isPending}
        className="w-full"
      >
        新規登録
      </Button>
      <FormSubmittedToast
        lastResult={lastResult}
        onSuccess={onSuccess}
        onError={onError}
      />
    </form>
  );
};
create-skill-form.tsx

そして、改良版のFormコンポーネントがこちら。

'use client';

- import { useActionState, useCallback } from 'react';
+ import { useActionState } from 'react';

- import { useRouter } from 'next/navigation';

import { useForm, getFormProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';

import { Field, FieldErrors } from '@/components/form/field';
import { FormInput } from '@/components/form/form-input';
import { FormMultiSelect } from '@/components/form/form-multi-select';
- 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 { SelectOptions } from '@/types';

- import { createSkill } from '../../actions/create';
+ import { useCreateSkill } from '../../api/use-create-skill';
import { createSKillSchema } from '../../schemas/create';

type CreateSkillFormProps = {
  projectOptions?: SelectOptions;
};

export const CreateSkillForm = ({
  projectOptions = [],
}: CreateSkillFormProps) => {
+ const { trigger } = useCreateSkill();
- const [lastResult, action, isPending] = useActionState(
-   createSkill,
-   undefined,
- );
+ const [lastResult, action, isPending] = useActionState(trigger, {});
  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 px-1"
      {...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>
      <Field>
        <Label htmlFor={fields.projectIds.id}>プロジェクト</Label>
        <FormMultiSelect meta={fields.projectIds} options={projectOptions} />
        <FieldErrors errors={fields.projectIds.errors} />
      </Field>
      <Button
        type="submit"
        form="create-skill"
        disabled={isPending}
        className="w-full"
      >
        新規登録
      </Button>
-     <FormSubmittedToast
-       lastResult={lastResult}
-       onSuccess={onSuccess}
-       onError={onError}
-     />
   </form>
  );
};

そして、Formコンポーネントで呼び出しているuseCreateSkillはこちら。

import { useRouter } from 'next/navigation';

import { toast } from '@/hooks/use-toast';

import { createSkill } from '../actions/create';

export const useCreateSkill = () => {
  const router = useRouter();
  // ! Server Actionsの関数をラップし、useActionStateに合った関数を作って呼び出す
  const trigger = async (_prevState: object, formData: FormData) => {
    const res = await createSkill(_prevState, formData);
    if (res.status === 'error') {
      toast({
        variant: 'destructive',
        description: '気になるスキルを作成できませんでした',
      });
    } else {
      toast({ description: '気になるスキルを作成しました' });
      router.back();
    }
    return {};
  };

  return { trigger };
};

Formコンポーネントからエラーハンドリング、トースト表示、ページ遷移を引き剥がし、1つのHooksにまとめることでコードの見通しが良くなりました。

さいごに

トースト機能の実装の参考になると嬉しいです・・!

参照