2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【You might not need useState】nuqs + Conformで作る「クエリパラメータ連動型Form」実践ガイド

Last updated at Posted at 2025-06-06

はじめに

今回ですが、クエリパラメータに連動したFormの実装方法について記載します。
昨今ではURLに状態を持たすことが当たり前になってきています。
それは、URLに状態を持たすことで以下の恩恵を受けられるからです。

  • ページを再読み込みしても、現在の状態を保持・再現できる
  • ブラウザの「戻る」「進む」ボタン操作でも状態が維持される
    • 特にフォームの場合、入力内容をそのまま復元できるため、ユーザー体験が向上する
  • SEOにも効果がある
    • クエリパラメータ付きのURLをそのままインデックスさせることで、特定の状態を検索結果に表示できる
  • 状態を簡単に他人と共有できる
    • URLを送るだけで、相手に同じ状態の画面を見せられる

このように、UX・SEOなどの面で大きなメリットがあるため、URLに持たせられる状態は持たせたほうが良いという考え方が一般的にです。

もちろん、デメリットもあります。
例えば、セキュリティリスクがあることだったり、URLに状態をもたせる場合の実装は複雑化しやすいといったことが挙げられます。

これらがあるので、セキュリティ上問題にならない情報のみURLに持たせることを共通認識として持つこと、そして、可能な限り実装を簡素化してくれるライブラリなどを採用すべき等のが私の考えになります。

前提が長くなりましたが、解説に移ろうと思います。

技術スタック

まずは、技術スタックですが、以下のスタックで開発を行いました。

  • Next.js 15.4.0
  • nuqs
  • conform
  • zod

作成Formのイメージ

そして以下が今回作成したFormのUIです。

完成図

URLを見るとわかると思いますが、各種Formの要素をweeklyReportEntryというオブジェクトで管理しているのがわかると思います。
このようにすると、ページを読み込んだ場合でも、現在の入力状態を保持できます。

article1.png

バリデーション時

バリデーションはConformで管理されており、それぞれどこで入力エラーがあるのかの表示を行えます。

validation.png

実装

まずは、大元となる型定義から実装していきます。

クエリパラメータの型定義

クエリパラメータの型を定義していきます。
nuqsの以下のドキュメントを参考に実装します。

今回は、ServerComponentでもClientComponentでもこの型を参照するため、createSearchParamsCahceParsersの両者を使用しています。

また、JSONオブジェクトとしてクエリパラメータを扱うため以下を参照し、zodでオブジェクトを定義していきます。

weekly-input-count-search-params-cache.ts
import {
  createSearchParamsCache,
  parseAsBoolean,
  parseAsJson,
} from 'nuqs/server'
import { z } from 'zod'

const weeklyReportEntrySchema = z.object({
  id: z.string().uuid(),
  project: z.string(),
  mission: z.string(),
  hours: z.number(),
  content: z.string(),
})

export const weeklyReportStateSchema = z.object({
  count: z.number(),
  entries: z.array(weeklyReportEntrySchema),
})

export const weeklyInputCountSearchParamsParsers = {
  weeklyReportEntry: parseAsJson(weeklyReportStateSchema.parse).withDefault({
    count: 1,
    entries: [
      {
        id: crypto.randomUUID(),
        project: '',
        mission: '',
        hours: 0,
        content: '',
      },
    ],
  }),
}

export type WeeklyInputCountSearchParams =
  typeof weeklyInputCountSearchParamsParsers

export type UpdateWeeklyInputCountSearchParams = Omit<
  WeeklyInputCountSearchParams,
  'isReference'
>

export const weeklyInputCountSearchParamsCache = createSearchParamsCache(
  weeklyInputCountSearchParamsParsers,
)

次にFormの型定義をしていきます。

zodによるForm要素の型定義

今回、クエリパラメータで管理する部分はcreateWeeklyReportSchemaとして定義したobjectになります。
(year・weekは気にしなくてよく、weeklyReportsの部分のみ注目していただければと思います。)

細かい部分は解説を省きますが、型としてはクエリパラメータの型とほぼ同じになります。

create-weekly-report-form-schema.ts
import { z } from 'zod'

export const createWeeklyReportSchema = z.object({
  id: z.string().uuid(),
  project: z.string({ required_error: 'プロジェクトを選択してください' }),
  mission: z.string({ required_error: 'ミッションを選択してください' }),
  content: z.string({ required_error: '内容を入力してください' }),
  hours: z
    .string()
    .transform((value) => Number(value))
    .refine((data) => data > 0, {
      message: '0より大きい数値で入力してください',
    }),
})

export const createWeeklyReportFormSchema = z.object({
  year: z.number(),
  week: z.number(),
  weeklyReports: z
    .array(createWeeklyReportSchema)
    .min(1, '週報の内容は1件以上必要です'),
})

export type CreateWeeklyReportFormSchema = z.infer<
  typeof createWeeklyReportFormSchema
>
export type CreateWeeklyReportSchema = z.infer<typeof createWeeklyReportSchema>

UI実装

ここまでで大本となるデータ型の定義が完了したので、実際のFormのUIを作成していきます。

Pageコンポーネントの実装

まず、UIの大本となるPage全体の実装は以下のようになっています。

src/app/weekly/list/[dates]/register/page.tsx
import { IconPlus, IconSend3 } from '@intentui/icons'
import { unauthorized } from 'next/navigation'
import type { SearchParams } from 'nuqs'
import { Suspense } from 'react'
import { Button } from '~/components/ui/intent-ui/button'
import { Heading } from '~/components/ui/intent-ui/heading'
import { Separator } from '~/components/ui/intent-ui/separator'
import { Skeleton } from '~/components/ui/intent-ui/skeleton'
import { getMissions } from '~/features/report-contexts/missions/server/fetcher'
import { getProjects } from '~/features/report-contexts/projects/server/fetcher'
import { CreateWeeklyReportForm } from '~/features/reports/weekly/components/create-weekly-report-form'
import { weeklyInputCountSearchParamsCache } from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'
import {
  getNextWeekDates,
  getYearAndWeek,
  splitDates,
} from '~/features/reports/weekly/utils/date-utils'
import { getServerSession } from '~/lib/get-server-session'
import type { NextPageProps } from '~/types'

export default async function WeeklyReportRegisterPage({
  params,
  searchParams,
}: NextPageProps<Record<'dates', string>, SearchParams>) {
  const session = await getServerSession()

  if (!session) {
    unauthorized()
  }

  const { dates } = await params
  const { startDate, endDate } = splitDates(dates)
  const { nextStartDate } = getNextWeekDates(startDate, endDate)
  const { year, week } = getYearAndWeek(nextStartDate)

  const { weeklyReportEntry } =
    await weeklyInputCountSearchParamsCache.parse(searchParams)

  const count = weeklyReportEntry.count

  const projectPromise = getProjects(undefined, session.user.id)
  const missionPromise = getMissions(undefined, session.user.id)

  const promises = Promise.all([projectPromise, missionPromise])

  return (
    <div className="p-4 lg:p-6 flex flex-col gap-4">
      <Suspense
        fallback={
          <>
            <Button size="square-petite" className="rounded-full mt-4">
              <IconPlus />
            </Button>
            <div className="space-y-2">
              {Array.from({ length: count > 0 ? count : 1 }).map(() => (
                <div
                  key={crypto.randomUUID()}
                  className="grid grid-cols-11 grid-rows-1 items-center gap-4 mx-auto py-2"
                >
                  <Skeleton className="col-span-2 h-7" />
                  <Skeleton className="col-span-2 h-7" />
                  <Skeleton className="col-span-2 h-7" />
                  <Skeleton className="col-span-4 h-7" />
                  <Skeleton className="col-span-1 size-9 rounded-full" />
                </div>
              ))}

              <Separator orientation="horizontal" />
              <div className="flex items-center gap-x-2 my-4">
                <span className="text-sm">合計時間:</span>
                <Heading className="text-muted-fg text-lg">0時間</Heading>
              </div>
              <Separator orientation="horizontal" />
              <div className="flex items-center justify-end gap-x-2 my-4">
                <Button>
                  登録する
                  <IconSend3 />
                </Button>
              </div>
            </div>
          </>
        }
      >
        <CreateWeeklyReportForm
          promises={promises}
          date={{ dates, year, week }}
        />
      </Suspense>
    </div>
  )
}

重要なのは以下の部分で、クエリパラメータの型定義で定義したcacheのPromise解決を行うことです。
これにより、propsとして定義しなくてもクエリパラメータを他のServerComponentが参照することができます。
(cacheを参照するため)

const { weeklyReportEntry, isReference } =
    await weeklyInputCountSearchParamsCache.parse(searchParams)

const count = weeklyReportEntry.count

また、countは画面を再読込した場合にSkeletonの数を実際のFormの要素を合わせるために使用しています。
これにより、Formの実態とfallbackでのUIの不一致を避けることができます。

Formコンポーネントの実装

次に、今回の肝となるFormの実装です。
以下の記事を参考にして実装を進めると、より理解が捗るかなと思います。

今回のような動的Formの場合、mapにて各要素を展開する実装をすることが多いと思われるので、ConformのFormProviderを使用して、FormのContext、つまり、Conformで管理している指定のFormの情報を子要素からも参照できるようにします。

実際にform.contextとしてFormProvidercontextを渡すことで、子要素でもProviderのcontextを参照することができます。

また、後ほど解説しますが、ConformはFormの要素を配列として取得できるgetFieldList()という関数を提供しているので、今回はmap部分でこれを展開することになります。
(このあたりは次項のカスタムフックの実装を見ていただくと、よりわかりやすいかもしれません)

removeButtonではFormの要素を減らす処理を施したコンポーネントを渡しています
(イメージの「−」表示のボタンがこれに該当します)
実際の詳しい処理は次項のカスタムフックの実装にて解説します。

create-weekly-report-form.tsx
'use client'

import { FormProvider, getFormProps, getInputProps } from '@conform-to/react'
import {
  IconMinus,
  IconPlus,
  IconSend3,
  IconTriangleExclamation,
} from '@intentui/icons'
import { use } from 'react'
import { Button } from '~/components/ui/intent-ui/button'
import { Form } from '~/components/ui/intent-ui/form'
import { Loader } from '~/components/ui/intent-ui/loader'
import { Separator } from '~/components/ui/intent-ui/separator'
import type { getMissions } from '~/features/report-contexts/missions/server/fetcher'
import type { getProjects } from '~/features/report-contexts/projects/server/fetcher'
import { TotalHours } from '~/features/reports/components/total-hours'
import { CreateWeeklyReportContentInputEntries } from '~/features/reports/weekly/components/create-weekly-report-content-input-entries'
import { useCreateWeeklyForm } from '~/features/reports/weekly/hooks/use-create-weekly-report-form'
import { weeklyInputCountSearchParamsParsers } from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'

type CreateWeeklyReportFormProps = {
  promises: Promise<
    [
      Awaited<ReturnType<typeof getProjects>>,
      Awaited<ReturnType<typeof getMissions>>,
    ]
  >
  date: {
    dates: string
    year: number
    week: number
  }
}

export function CreateWeeklyReportForm({
  promises,
  date,
}: CreateWeeklyReportFormProps) {
  const [projectsResponse, missionsResponse] = use(promises)

  const {
    action,
    isPending,
    form,
    fields,
    weeklyReports,
    totalHours,
    handleAdd,
    handleRemove,
    getError,
  } = useCreateWeeklyForm(weeklyInputCountSearchParamsParsers, date)

  return (
    <>
      <div className="space-y-2">
        {fields.weeklyReports.errors && (
          <div className="bg-danger/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-danger">
            <IconTriangleExclamation className="size-4" />
            <p>{fields.weeklyReports.errors}</p>
          </div>
        )}
        {getError() && (
          <div className="bg-danger/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-danger">
            <IconTriangleExclamation className="size-4" />
            <p>{getError()}</p>
          </div>
        )}
        <Button
          size="square-petite"
          onPress={handleAdd}
          className="rounded-full mt-4"
          isDisabled={isPending}
        >
          <IconPlus />
        </Button>
      </div>
      <FormProvider context={form.context}>
        <Form className="space-y-2" action={action} {...getFormProps(form)}>
          <input {...getInputProps(fields.year, { type: 'hidden' })} />
          <input {...getInputProps(fields.week, { type: 'hidden' })} />
          {weeklyReports.map((weeklyReport) => (
            <CreateWeeklyReportContentInputEntries
              key={weeklyReport.key}
              id={weeklyReport.value?.id}
              formId={form.id}
              name={weeklyReport.name}
              projects={projectsResponse.projects}
              missions={missionsResponse.missions}
              removeButton={
                <Button
                  size="square-petite"
                  intent="danger"
                  onPress={() => {
                    handleRemove(weeklyReport.getFieldset().id.value ?? '')
                  }}
                  isDisabled={isPending}
                  className="rounded-full mt-6"
                >
                  <IconMinus />
                </Button>
              }
            />
          ))}
          <Separator orientation="horizontal" />
          <TotalHours totalHours={totalHours} />
          <Separator orientation="horizontal" />
          <div className="flex items-center justify-end gap-x-2 my-4">
            <Button isDisabled={isPending} type="submit">
              {isPending ? '登録中...' : '登録する'}
              {isPending ? <Loader /> : <IconSend3 />}
            </Button>
          </div>
        </Form>
      </FormProvider>
    </>
  )
}

カスタムフックの実装

それではUIのロジックをもつカスタムフックの処理内容を解説します。

まずは、クエリパラメータをクライアント側で追加・更新する必要があるので、そのためのhooksを作ります。

ここではnuqsが提供してくれるuseQueryStatesを使用します。
optionでhistory: 'push'とすることでURLの履歴に追加され、shallow: falseとすることで、ClientComponentで行ったURLの変更がServerComponentに通知されるようにします。

shallowについては、デフォルトでtrueになっており、デフォルトではServerComponentに通知されないので、注意が必要です。

use-weekly-report-search-params.ts
import { useQueryStates } from 'nuqs'
import type {
  UpdateWeeklyInputCountSearchParams,
  WeeklyInputCountSearchParams,
} from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'

export function useWeeklyReportSearchParams(
  initialWeeklyInputCountSearchParamsParsers:
    | WeeklyInputCountSearchParams
    | UpdateWeeklyInputCountSearchParams,
) {
  const [{ weeklyReportEntry }, setWeeklyReportEntry] = useQueryStates(
    initialWeeklyInputCountSearchParamsParsers,
    {
      history: 'push',
      shallow: false,
    },
  )

  return {
    weeklyReportEntry,
    setWeeklyReportEntry,
  } as const
}

次にFormProviderを定義している親側のFormのUIカスタムフックの実装です。
ここでは、ServerActionを管理するなどの実装をしています。
withCallbackなど見慣れない処理もありますが、本記事とは内容が逸れるので、詳しくは以下の記事をご参照ください。

use-create-weekly-report-form.ts
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { toast } from 'sonner'
import { createWeeklyReportAction } from '~/features/reports/weekly/actions/create-weekly-report-action'
import { useWeeklyReportSearchParams } from '~/features/reports/weekly/hooks/use-weekly-report-search-params'
import {
  type CreateWeeklyReportFormSchema,
  createWeeklyReportFormSchema,
} from '~/features/reports/weekly/types/schemas/create-weekly-report-form-schema'
import type { WeeklyInputCountSearchParams } from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'
import { useSafeForm } from '~/hooks/use-safe-form'
import { withCallbacks } from '~/utils/with-callbacks'

export function useCreateWeeklyForm(
  initialWeeklyInputCountSearchParamsParsers: WeeklyInputCountSearchParams,
  date: {
    dates: string
    year: number
    week: number
  },
) {
  const { weeklyReportEntry, setWeeklyReportEntry } =
    useWeeklyReportSearchParams(initialWeeklyInputCountSearchParamsParsers)

  const router = useRouter()

  const [lastResult, action, isPending] = useActionState(
    withCallbacks(createWeeklyReportAction, {
      onSuccess() {
        toast.success('週報の作成に成功しました')
        router.push(`/weekly/list/${date.dates}`)
      },
      onError(result) {
        if (result.error) {
          const isUnauthorized = result.error.message?.includes('Unauthorized')

          if (isUnauthorized) {
            toast.error('セッションが切れました。再度ログインしてください', {
              cancel: {
                label: 'ログイン',
                onClick: () => router.push('/sign-in'),
              },
            })
            return
          }
        }

        toast.error('週報の作成に失敗しました')
      },
    }),
    null,
  )

  const [form, fields] = useSafeForm<CreateWeeklyReportFormSchema>({
    constraint: getZodConstraint(createWeeklyReportFormSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: createWeeklyReportFormSchema })
    },
    defaultValue: {
      year: date.year,
      week: date.week,
      weeklyReports: weeklyReportEntry.entries.map((entry) => ({
        ...entry,
        hours: entry.hours.toString(),
      })),
    },
  })

  const weeklyReports = fields.weeklyReports.getFieldList()

  const totalHours = weeklyReports.reduce((acc, entry) => {
    const hours = Number(entry.value?.hours ?? 0)
    return acc + (hours > 0 ? hours : 0)
  }, 0)

  const handleAdd = () => {
    const newEntry = {
      id: crypto.randomUUID(),
      project: '',
      mission: '',
      content: '',
      hours: 0,
    } as const satisfies (typeof weeklyReportEntry.entries)[number]

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          count: prev.weeklyReportEntry.count + 1,
          entries: [...prev.weeklyReportEntry.entries, newEntry],
        },
      }
    })

    form.insert({
      name: fields.weeklyReports.name,
      defaultValue: {
        ...newEntry,
        hours: '0',
      },
    })
  }

  const handleRemove = (id: string) => {
    const index = weeklyReports.findIndex((entry) => entry.value?.id === id)

    if (index === -1) {
      return
    }

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      const filteredEntries = prev.weeklyReportEntry.entries.filter(
        (e) => e.id !== id,
      )

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          count:
            prev.weeklyReportEntry.count > 1
              ? prev.weeklyReportEntry.count - 1
              : 1,
          entries: filteredEntries,
        },
      }
    })

    form.remove({
      name: fields.weeklyReports.name,
      index,
    })
  }

  const getError = () => {
    if (lastResult?.error && Array.isArray(lastResult.error.message)) {
      const filteredMessages = lastResult.error.message.filter(
        (msg) => !msg.includes('Unauthorized'),
      )

      return filteredMessages.length > 0
        ? filteredMessages.join(', ')
        : undefined
    }

    return
  }

  return {
    action,
    isPending,
    form,
    fields,
    weeklyReports,
    totalHours,
    handleAdd,
    handleRemove,
    getError,
  } as const
}

処理が長いのでいかに重要な部分を切り出しました。
この部分について詳細に解説します。

useSafeFormはConformのuseFormをラップしたカスタムフックで、defaultValueの定義を必須にするというだけのhookなので、機能としてはuseFormと同じです。

このuseSafeFormでConformで管理するFormスキーマを定義します。
defaultValueにはクエリパラメータの値を渡すようにすることがここでは重要です。
これでクエリパラメータとFormの同期はほぼ行われたと言っても過言ではないです。

const [form, fields] = useSafeForm<CreateWeeklyReportFormSchema>({
  constraint: getZodConstraint(createWeeklyReportFormSchema),
  lastResult,
  onValidate({ formData }) {
    return parseWithZod(formData, { schema: createWeeklyReportFormSchema })
  },
  defaultValue: {
    year: date.year,
    week: date.week,
    weeklyReports: weeklyReportEntry.entries.map((entry) => ({
      ...entry,
      hours: entry.hours.toString(),
    })),
  },
})

const weeklyReports = fields.weeklyReports.getFieldList()

const totalHours = weeklyReports.reduce((acc, entry) => {
  const hours = Number(entry.value?.hours ?? 0)
  return acc + (hours > 0 ? hours : 0)
}, 0)

const handleAdd = () => {
  const newEntry = {
    id: crypto.randomUUID(),
    project: '',
    mission: '',
    content: '',
    hours: 0,
  } as const satisfies (typeof weeklyReportEntry.entries)[number]

  setWeeklyReportEntry((prev) => {
    if (!prev) {
      return prev
    }

    return {
      ...prev,
      weeklyReportEntry: {
        ...prev.weeklyReportEntry,
        count: prev.weeklyReportEntry.count + 1,
        entries: [...prev.weeklyReportEntry.entries, newEntry],
      },
    }
  })

  form.insert({
    name: fields.weeklyReports.name,
    defaultValue: {
      ...newEntry,
      hours: '0',
    },
  })
}

const handleRemove = (id: string) => {
  const index = weeklyReports.findIndex((entry) => entry.value?.id === id)

  if (index === -1) {
    return
  }

  setWeeklyReportEntry((prev) => {
    if (!prev) {
      return prev
    }

    const filteredEntries = prev.weeklyReportEntry.entries.filter(
      (e) => e.id !== id,
    )

    return {
      ...prev,
      weeklyReportEntry: {
        ...prev.weeklyReportEntry,
        count:
          prev.weeklyReportEntry.count > 1
            ? prev.weeklyReportEntry.count - 1
            : 1,
        entries: filteredEntries,
      },
    }
  })

  form.remove({
    name: fields.weeklyReports.name,
    index,
  })
}

次にこの部分ですが、これは前述したFormのInput要素をlistとして返す(mapとして展開できるようにする)ためのgetFieldList()です。

const weeklyReports = fields.weeklyReports.getFieldList()

次にFormの要素を追加するロジックですが、useStateのsetStateと似たようなことをしています。

  1. 追加される値を定義
  2. クエリパラメータ追加
  3. form.insertによりformの末尾に追加する

Conformではform.insertを使用することで動的Formを簡単に実装できます。
nameにどのForm要素を増やすのかを指定して、defaultValueには追加したい値をzodスキーマと同じ型で渡すだけでFormの末尾要素として追加されます。
(zodと型を揃えるためhoursは文字列としています)

const handleAdd = () => {
  const newEntry = {
    id: crypto.randomUUID(),
    project: '',
    mission: '',
    content: '',
    hours: 0,
  } as const satisfies (typeof weeklyReportEntry.entries)[number]

  setWeeklyReportEntry((prev) => {
    if (!prev) {
      return prev
    }

    return {
      ...prev,
      weeklyReportEntry: {
        ...prev.weeklyReportEntry,
        count: prev.weeklyReportEntry.count + 1,
        entries: [...prev.weeklyReportEntry.entries, newEntry],
      },
    }
  })

  form.insert({
    name: fields.weeklyReports.name,
    defaultValue: {
      ...newEntry,
      hours: '0',
    },
  })
}

最後に要素が消された時の処理(removeButtonがクリックされた場合の処理)ですが、以下のようにします。
今回は各要素をid(UUID)で管理しているので、まずは、idから配列のindexを探します。

そして、ここでもuseStateのsetStateと同じように、クエリパラメータから該当の要素を取り除く処理を記載します。

最後に、Conformのform.removeを使用します。
form.insertと同じように、どの要素を取り除くのかをnameで指定します。
また、removeではどのの要素を取り除くかをindexにて指定する必要があるので、indexを渡します。

だからこそ、id管理の場合は、findIndexにて取得したindexをremoveに渡してあげないといけないです。
id管理にすることで、removeをした際にindexがずれて、Reactがコンポーネントのkey認識を誤ることがないようにすることができます。

const handleRemove = (id: string) => {
  const index = weeklyReports.findIndex((entry) => entry.value?.id === id)

  if (index === -1) {
    return
  }

  setWeeklyReportEntry((prev) => {
    if (!prev) {
      return prev
    }

    const filteredEntries = prev.weeklyReportEntry.entries.filter(
      (e) => e.id !== id,
    )

    return {
      ...prev,
      weeklyReportEntry: {
        ...prev.weeklyReportEntry,
        count:
          prev.weeklyReportEntry.count > 1
            ? prev.weeklyReportEntry.count - 1
            : 1,
        entries: filteredEntries,
      },
    }
  })

  form.remove({
    name: fields.weeklyReports.name,
    index,
  })
}

ここまでで動的Formとする処理が完了したことになります。
あとは、子コンポーネントにConfrom(useSafeFormの返却値のform)の情報との紐づけおよび、Inputなどの値が変更となったときの処理を定義するだけで、完全にURLと同期した動的Formを作成できます。

FormProviderの子コンポーネントの実装

それでは、FormProviderの子コンポーネントの実装を見ていきます。
ここではまず、FormProviderのcontextを参照するなどし、Confrom(useSafeFormの返却値のform)の情報との紐づけを行っていきます。

例のごとく、肝となる処理はカスタムフックに記載しているので、propsとCombboxなどのイベントハンドラ()onSelectionChangeなどの処理をざっくり確認していただければと思います。

create-weekly-report-content-input-entries.tsx
import { type FieldName, getInputProps } from '@conform-to/react'
import type { InferResponseType } from 'hono'
import type { JSX } from 'react'
import { useFormStatus } from 'react-dom'
import { filter, pipe } from 'remeda'
import { ComboBox } from '~/components/ui/intent-ui/combo-box'
import { NumberField } from '~/components/ui/intent-ui/number-field'
import { TextField } from '~/components/ui/intent-ui/text-field'
import { useCreateWeeklyReportContentInputEntries } from '~/features/reports/weekly/hooks/use-create-weekly-report-content-input-entries'
import type {
  CreateWeeklyReportFormSchema,
  CreateWeeklyReportSchema,
} from '~/features/reports/weekly/types/schemas/create-weekly-report-form-schema'
import { weeklyInputCountSearchParamsParsers } from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'
import type { client } from '~/lib/rpc'

type CreateWeeklyReportContentInputEntriesProps = {
  id?: string
  projects: InferResponseType<typeof client.api.projects.$get, 200>['projects']
  missions: InferResponseType<typeof client.api.missions.$get, 200>['missions']
  formId: string
  name: FieldName<CreateWeeklyReportSchema, CreateWeeklyReportFormSchema>
  removeButton: JSX.Element
}

export function CreateWeeklyReportContentInputEntries({
  id,
  projects,
  missions,
  formId,
  name,
  removeButton,
}: CreateWeeklyReportContentInputEntriesProps) {
  const {
    field,
    missionInput,
    contentInput,
    hoursInput,
    projectId,
    missionId,
    handleChangeItem,
    handleChangeValue,
  } = useCreateWeeklyReportContentInputEntries(
    weeklyInputCountSearchParamsParsers,
    formId,
    name,
    projects,
  )

  const { pending } = useFormStatus()

  return (
    <div className="grid grid-cols-11 grid-rows-1 items-center gap-4 mx-auto py-2">
      <input {...getInputProps(field.id, { type: 'hidden' })} />
      <div className="col-span-2">
        <ComboBox
          {...getInputProps(field.project, { type: 'text' })}
          label="プロジェクト"
          placeholder="プロジェクトを選択"
          onSelectionChange={(key) => {
            handleChangeItem(id ?? '', key, 'project')
          }}
          selectedKey={projectId}
          isDisabled={pending}
        >
          <ComboBox.Input />
          <ComboBox.List items={projects}>
            {(project) => (
              <ComboBox.Option id={project.id}>{project.name}</ComboBox.Option>
            )}
          </ComboBox.List>
        </ComboBox>
        <span id={field.project.errorId} className="text-sm text-red-500">
          {field.project.errors}
        </span>
      </div>
      <div className="col-span-2">
        <ComboBox
          {...getInputProps(field.mission, { type: 'text' })}
          label="ミッション"
          placeholder="ミッションを選択"
          onSelectionChange={(key) => {
            handleChangeItem(id ?? '', key, 'mission')
          }}
          selectedKey={missionId}
          isDisabled={pending}
        >
          <ComboBox.Input />
          <ComboBox.List
            items={
              projectId && !missionInput.value
                ? pipe(
                    missions,
                    filter((mission) => mission.projectId === projectId),
                  )
                : missions
            }
          >
            {(mission) => (
              <ComboBox.Option id={mission.id}>{mission.name}</ComboBox.Option>
            )}
          </ComboBox.List>
        </ComboBox>
        <span id={field.mission.errorId} className="text-sm text-red-500">
          {field.mission.errors}
        </span>
      </div>
      <div className="col-span-2">
        <NumberField
          step={0.25}
          label="時間"
          value={Number(hoursInput.value)}
          onChange={(val) => handleChangeValue(id ?? '', val)}
          errorMessage={''}
          isDisabled={pending}
        />
        <span id={field.hours.errorId} className="text-sm text-red-500">
          {field.hours.errors}
        </span>
      </div>
      <div className="col-span-4">
        <TextField
          {...getInputProps(field.content, { type: 'text' })}
          label="内容"
          placeholder="タスク内容を入力"
          value={contentInput.value}
          onChange={(val) => {
            handleChangeValue(id ?? '', val)
          }}
          isDisabled={pending}
          errorMessage={''}
        />
        <span id={field.content.errorId} className="text-sm text-red-500">
          {field.content.errors}
        </span>
      </div>
      <div className="col-span-1">{removeButton}</div>
    </div>
  )
}

カスタムフックの実装

ここでも重要な部分のみ抜粋しながら、解説します。
まずは、全体を示すため、以下を記載いたします。

use-create-weekly-report-content-input-entries.ts
import { type FieldName, useField, useInputControl } from '@conform-to/react'
import type { InferResponseType } from 'hono'
import { useState } from 'react'
import type { Key } from 'react-stately'
import { filter, find, pipe } from 'remeda'
import { useWeeklyReportSearchParams } from '~/features/reports/weekly/hooks/use-weekly-report-search-params'
import type {
  CreateWeeklyReportFormSchema,
  CreateWeeklyReportSchema,
} from '~/features/reports/weekly/types/schemas/create-weekly-report-form-schema'
import type { WeeklyInputCountSearchParams } from '~/features/reports/weekly/types/search-params/weekly-input-count-search-params-cache'
import type { client } from '~/lib/rpc'

export function useCreateWeeklyReportContentInputEntries(
  initialWeeklyInputCountSearchParamsParsers: WeeklyInputCountSearchParams,
  formId: string,
  name: FieldName<CreateWeeklyReportSchema, CreateWeeklyReportFormSchema>,
  projects: InferResponseType<typeof client.api.projects.$get, 200>['projects'],
) {
  const [meta] = useField(name, { formId })
  const field = meta.getFieldset()

  const projectInput = useInputControl(field.project)
  const missionInput = useInputControl(field.mission)
  const contentInput = useInputControl(field.content)
  const hoursInput = useInputControl(field.hours)

  const { setWeeklyReportEntry } = useWeeklyReportSearchParams(
    initialWeeklyInputCountSearchParamsParsers,
  )

  // form resetがConformのものでは反映されないため
  const [projectId, setProjectId] = useState<Key | null>(
    projectInput.value ?? null,
  )
  const [missionId, setMissionId] = useState<Key | null>(
    missionInput.value ?? null,
  )

  const handleChangeItem = (
    id: string,
    newItem: Key | null,
    kind: 'project' | 'mission',
  ) => {
    if (!(id && newItem)) {
      return
    }

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      const updatedEntries = prev.weeklyReportEntry.entries.map((e) =>
        e.id === id ? { ...e, [kind]: newItem } : e,
      )

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          entries: updatedEntries,
        },
      }
    })

    if (kind === 'project') {
      setProjectId(newItem)
      projectInput.change(newItem.toString())
      setMissionId(null)
      missionInput.change(undefined)

      setWeeklyReportEntry((prev) => {
        if (!prev) {
          return prev
        }

        const updatedEntries = prev.weeklyReportEntry.entries.map((e) =>
          e.id === id ? { ...e, project: newItem.toString(), mission: '' } : e,
        )

        return {
          ...prev,
          weeklyReportEntry: {
            ...prev.weeklyReportEntry,
            entries: updatedEntries,
          },
        }
      })
    } else {
      missionId
        ? pipe(
            projects,
            filter((project) =>
              project.missions.some((mission) => mission.id === missionId),
            ),
          )
        : projects
      setMissionId(newItem)
      missionInput.change(newItem.toString())

      const findProject = pipe(
        projects,
        find((project) =>
          project.missions.some((mission) => mission.id === newItem),
        ),
      )

      setWeeklyReportEntry((prev) => {
        if (!prev) {
          return prev
        }

        const updatedEntries = prev.weeklyReportEntry.entries.map((e) => {
          if (e.id === id) {
            return {
              ...e,
              mission: newItem.toString(),
              project: findProject?.id ?? '',
            }
          }
          return e
        })

        return {
          ...prev,
          weeklyReportEntry: {
            ...prev.weeklyReportEntry,
            entries: updatedEntries,
          },
        }
      })

      setProjectId(findProject?.id ?? null)
      projectInput.change(findProject?.id.toString() ?? '')
    }
  }

  const handleChangeValue = (id: string, newValue: string | number) => {
    if (!id) {
      return
    }

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      const key = typeof newValue === 'string' ? 'content' : 'hours'
      const updatedEntries = prev.weeklyReportEntry.entries.map((e) =>
        e.id === id ? { ...e, [key]: newValue } : e,
      )

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          entries: updatedEntries,
        },
      }
    })

    if (typeof newValue === 'string') {
      contentInput.change(newValue)
    } else {
      hoursInput.change(newValue.toString())
    }
  }

  return {
    field,
    missionInput,
    contentInput,
    hoursInput,
    projectId,
    missionId,
    handleChangeItem,
    handleChangeValue,
  } as const
}

まず、FormProviderの情報との紐づけを行います。
ここではuseFeildというフックを使用します。

これは、nameとformの識別子としてformIdを渡すことで実現できます。
各値は親から以下のように渡されます。
formId={form.id}(formの識別子)
name={weeklyReport.name}(動的にしたい要素のname)

そして、formのmeta情報を取得できたら、meta情報に含まれるgetFieldset()にて各Form要素のfield情報を取得することができます。

あとはこれを、UIと紐づけるだけです。

以下のように、ConformのgetInputPropsなどを使用して、Conformの定番の実装と同じように紐づけをするだけでしっかりとConformでform管理がなされるよういなります
{...getInputProps(field.project, { type: 'text' })}

また、useInputControlを使用することで、イベント発火時に指定したInputなどの要素の状態を管理することができます。
(例えば、任意のタイミングでInputの要素を書き換えたい場合には、input.changeなどとすることで制御できます)

const [meta] = useField(name, { formId })
const field = meta.getFieldset()

const projectInput = useInputControl(field.project)
const missionInput = useInputControl(field.mission)
const contentInput = useInputControl(field.content)
const hoursInput = useInputControl(field.hours)

残るは、イベントハンドラ(ComboBoxのonSelectionChangeやTextFeildなどのonChangeが発火した時)の処理の詳細を記載していきます。

以下が、onSelectionChange発火時に呼ばれる処理です。
ここでは、該当のクエリパラメータの要素を更新して、projectInput.change(newItem.toString())など useInputControl()で定義した値を使って、イベントの処理を実装していきます。

今回は2つのComboBoxの組み合わせなので、親となるProjectが再選択されたらMissionの設定値がリセットされるなど、少し処理が複雑ですが、基本的なクエリパラメータの更新 → Form要素の更新などの制御の流れは変わりません。

const handleChangeItem = (
  id: string,
  newItem: Key | null,
  kind: 'project' | 'mission',
) => {
  if (!(id && newItem)) {
    return
  }

  setWeeklyReportEntry((prev) => {
    if (!prev) {
      return prev
    }

    const updatedEntries = prev.weeklyReportEntry.entries.map((e) =>
      e.id === id ? { ...e, [kind]: newItem } : e,
    )

    return {
      ...prev,
      weeklyReportEntry: {
        ...prev.weeklyReportEntry,
        entries: updatedEntries,
      },
    }
  })

  if (kind === 'project') {
    setProjectId(newItem)
    projectInput.change(newItem.toString())
    setMissionId(null)
    missionInput.change(undefined)

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      const updatedEntries = prev.weeklyReportEntry.entries.map((e) =>
        e.id === id ? { ...e, project: newItem.toString(), mission: '' } : e,
      )

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          entries: updatedEntries,
        },
      }
    })
  } else {
    missionId
      ? pipe(
          projects,
          filter((project) =>
            project.missions.some((mission) => mission.id === missionId),
          ),
        )
      : projects
    setMissionId(newItem)
    missionInput.change(newItem.toString())

    const findProject = pipe(
      projects,
      find((project) =>
        project.missions.some((mission) => mission.id === newItem),
      ),
    )

    setWeeklyReportEntry((prev) => {
      if (!prev) {
        return prev
      }

      const updatedEntries = prev.weeklyReportEntry.entries.map((e) => {
        if (e.id === id) {
          return {
            ...e,
            mission: newItem.toString(),
            project: findProject?.id ?? '',
          }
        }
        return e
      })

      return {
        ...prev,
        weeklyReportEntry: {
          ...prev.weeklyReportEntry,
          entries: updatedEntries,
        },
      }
    })

    setProjectId(findProject?.id ?? null)
    projectInput.change(findProject?.id.toString() ?? '')
  }
}

handleChangeValueも同じようにクエリパラメータの更新処理および、Formの該当要素の更新処理を記載しています。

理解を深めたい方はコードリーディングとして精読してみることをおすすめします。

長くなりました、これでクエリパラメータと同期する動的Formの実装は完了です。

おわりに

今回は、nuqs と Conform を活用して、URLの検索パラメータと連動する動的なフォームを実装してみました。いかがだったでしょうか?

フォームに限らず、テーブルのソートやページネーションなど、URLに状態を持たせる場面は多くのプロジェクトで見られます。こうした実装において nuqs のようなライブラリは、状態管理や保守性の観点から非常に有用であり、今後ますます重要になると感じています。

まずはシンプルな検索付きテーブルなどから試してみるのが良いでしょう。URLに状態を持たせることで、UXの向上・再現性の確保・共有のしやすさなど多くのメリットが得られます。

ぜひこの機会に、URLと状態管理のベストプラクティスについて理解を深めてみてください。

参考文献

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?