✍️

Next.js と React Hook Form で確認画面付きのフォームを作りたい

2024/12/17に公開

はじめに

App Router 環境下の Next.js では React Server Component を使用するのがスタンダードです。
確認画面付きのフォームでは、入力した内容を引き継いで表示させる必要があるため、page.tsx を Server Component に保ちつつ、作るには少し工夫が必要です。

今回は React Hook Form を使用して、これらの課題に対する解決策を提案します。
この記事では、シンプルな予約フォームを例に、確認画面付きのフォームの実装方法を解説していきます。

使用する package

  • Next.js
  • React Hook Form
  • Shadcn UI

ファイル構成

今回のフォームを作成するに当たり、必要なファイル構成は以下の通りとなります。components や actions の置き場所はプロジェクトに応じて変えるのが良いと思います。
大事なのは、app 配下の reservations 配下のファイル構成です。
Route Groups を使用して、(create)ディレクトリ配下に layout.tsx を配置しています。
これが今回の肝です。

画面パスは以下のようになります。

  • /reservations/new: 入力画面
  • /reservations/new/confirm: 確認画面
src
├── actions
│   └── reservations
│       └── create
│           ├── create-reservation.ts
│           └── schema.ts
├── app
│   ├── layout.tsx
│   ├── page.tsx
│   └── reservations
│       └── (create)
│           ├── layout.tsx
│           └── new
│               ├── confirm
│               │   └── page.tsx
│               └── page.tsx
└─  components
    ├── confirm-reservation-form.tsx
    ├── create-reservation-form.tsx
    └── form-provider.tsx

実装ポイント

FormProvider を Client Component として切り出す

useFormContext と FormProvider を使うと、useForm の状態やメソッドを useFormContext を通じて子コンポーネントに渡すことができます。
今回は入力画面 /new/page.tsx と確認画面 /new/confirm/page.tsx の共通 Layout を作成することで、入力画面で入力した値を共通 Layout で受け取り、確認画面に値を渡すことができます。
また、FormProvider を Client Component として切り出すことで、layout.tsx が Server Component のまま、データフェッチした値を defaultValues としてセットすることが可能です。

components/form-provider.tsx
'use client';
import { schema, type FormData } from '@/actions/reservations/create/schema';
import { zodResolver } from '@hookform/resolvers/zod';
import type { JSX, ReactNode } from 'react';
import { FormProvider as RhfFormProvider, useForm } from 'react-hook-form';

export type FormProviderProps = {
  children: ReactNode;
  defaultValues: FormData;
};

export default function FormProvider({ children, defaultValues }: FormProviderProps): JSX.Element {
  const methods = useForm<FormData>({
    defaultValues,
    mode: 'onChange',
    resolver: zodResolver(schema),
  });

  return <RhfFormProvider {...methods}>{children}</RhfFormProvider>;
}

app/reservations/(create)/layout.tsx
import FormProvider from '@/components/form-provider';
import type { JSX, ReactNode } from 'react';
type LayoutProps = {
  children: ReactNode;
};

export default async function Layout({ children }: LayoutProps): Promise<JSX.Element> {
  const defaultValues = {
    name: '',
    email: '',
    date: new Date(),
    startTime: '',
    endTime: '',
  };

  return <FormProvider defaultValues={defaultValues}>{children}</FormProvider>;
}

入力画面の作成

上記の Layout を作成することで、Submit するときは確認画面に遷移させることで、値を渡すことができます。

app/reservations/(create)/new/page.tsx
import CreateReservationForm from '@/components/create-reservation-form';
import type { JSX } from 'react';

export default function Page(): JSX.Element {
  return <CreateReservationForm />;
}
components/create-reservation-form.tsx
'use client';
import type { FormData } from '@/actions/reservations/create/schema';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { format, startOfDay } from 'date-fns';
import { useRouter } from 'next/navigation';
import type { JSX } from 'react';
import { useFormContext } from 'react-hook-form';

const timeSlots = Array.from({ length: 22 }, (_, i) => {
  const hour = Math.floor(i / 2) + 9;
  const minute = i % 2 === 0 ? '00' : '30';
  return `${hour.toString().padStart(2, '0')}:${minute}`;
});

export default function CreateReservationForm(): JSX.Element {
  const router = useRouter();
  const form = useFormContext<FormData>();

  const onSubmit = (): void => {
    router.push('/reservations/new/confirm');
  };

  return (
    <Card className="w-2/3">
      <CardHeader>
        <CardTitle>予約フォーム</CardTitle>
        <CardDescription>以下の情報を入力して予約を作成してください。</CardDescription>
      </CardHeader>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <CardContent className="space-y-6">
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>お名前</FormLabel>
                  <FormControl>
                    <Input placeholder="山田太郎" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>メールアドレス</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="taro@example.com" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="date"
              render={({ field }) => (
                <FormItem className="flex flex-col">
                  <FormLabel>予約日</FormLabel>
                  <Popover>
                    <PopoverTrigger asChild>
                      <FormControl>
                        <Button
                          variant="outline"
                          className={cn(
                            'w-[240px] pl-3 text-left font-normal',
                            !field.value && 'text-muted-foreground',
                          )}
                        >
                          {field.value ? format(field.value, 'yyyy年MM月dd日') : <span>日付を選択してください</span>}
                        </Button>
                      </FormControl>
                    </PopoverTrigger>
                    <PopoverContent className="w-auto p-0" align="start">
                      <Calendar
                        mode="single"
                        selected={field.value}
                        onSelect={field.onChange}
                        disabled={(date) => date < startOfDay(new Date())}
                        initialFocus
                      />
                    </PopoverContent>
                  </Popover>
                  <FormMessage />
                </FormItem>
              )}
            />
            <div className="grid grid-cols-2 gap-4">
              <FormField
                control={form.control}
                name="startTime"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>開始時間</FormLabel>
                    <Select onValueChange={field.onChange} defaultValue={field.value}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="開始時間を選択" />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        {timeSlots.map((time) => (
                          <SelectItem key={time} value={time}>
                            {time}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="endTime"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>終了時間</FormLabel>
                    <Select onValueChange={field.onChange} defaultValue={field.value}>
                      <FormControl>
                        <SelectTrigger>
                          <SelectValue placeholder="終了時間を選択" />
                        </SelectTrigger>
                      </FormControl>
                      <SelectContent>
                        {timeSlots.map((time) => (
                          <SelectItem key={time} value={time}>
                            {time}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
          </CardContent>
          <CardFooter>
            <Button type="submit">予約を作成</Button>
          </CardFooter>
        </form>
      </Form>
    </Card>
  );
}

確認画面の作成

直リンクで確認画面にアクセスされた場合や、確認画面で画面をリロードされて入力した値が初期化された場合を考慮して、入力画面にリダイレクトする処理を追加しています。

app/reservations/(create)/new/confirm/page.tsx
import ConfirmReservationForm from '@/components/confirm-reservation-form';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { JSX } from 'react';

export default async function Page(): Promise<JSX.Element> {
  const headersList = await headers();
  const referer = headersList.get('referer');
  const host = headersList.get('host');

  // 直リンクで確認画面にアクセスされた場合は入力画面にリダイレクトさせる
  if (!referer || !referer.includes(host ?? '')) {
    redirect('/reservations/new');
  }

  return <ConfirmReservationForm />;
}
components/confirm-reservation-form.tsx
'use client';
import createReservation from '@/actions/reservations/create/create-reservation';
import type { FormData } from '@/actions/reservations/create/schema';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
import { useRouter } from 'next/navigation';
import { useEffect, type JSX } from 'react';
import { useFormContext } from 'react-hook-form';
export default function ConfirmReservationForm(): JSX.Element {
  const form = useFormContext<FormData>();
  const values = form.getValues();
  const router = useRouter();

  const onSubmit = form.handleSubmit(async (data) => {
    await createReservation(data);
  });

  useEffect(() => {
    if (Object.keys(values).length === 0) {
      router.push('/reservations/new');
    }
  }, [values, router]);

  return (
    <Card className="w-2/3">
      <CardHeader>
        <CardTitle>予約内容の確認</CardTitle>
        <CardDescription>入力した内容をご確認ください</CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="grid grid-cols-4 items-center">
          <div className="font-medium">お名前</div>
          <div className="col-span-3">{values.name}</div>
        </div>
        <div className="grid grid-cols-4 items-center">
          <div className="font-medium">メールアドレス</div>
          <div className="col-span-3">{values.email}</div>
        </div>
        <div className="grid grid-cols-4 items-center">
          <div className="font-medium">予約日</div>
          <div className="col-span-3">{values.date ? format(values.date, 'yyyy年MM月dd日') : ''}</div>
        </div>
        <div className="grid grid-cols-4 items-center">
          <div className="font-medium">時間</div>
          <div className="col-span-3">
            {values.startTime}{values.endTime}
          </div>
        </div>
      </CardContent>
      <CardFooter className="flex gap-4">
        <Button type="button" variant="outline" onClick={() => router.back()}>
          戻る
        </Button>
        <Button onClick={() => onSubmit()}>予約を確定する</Button>
      </CardFooter>
    </Card>
  );
}

最後に

このアプローチを使用することで、App Router 環境下の Next.js で React Server Component を活用しながら、確認画面付きのフォームを効果的に実装することができます。
新規入力画面だけでなく、初期値を DB から取得するような編集画面の実装にも非常に役立ちます。
参考になりましたら幸いです!

フクロウラボ エンジニアブログ

Discussion