UPSIDER Tech Blog

Reactで絞り込みFormの作り方

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事では、Reactを使用した絞り込みフォームの作り方をご紹介します。

Formはアプリケーションのコア機能の1つとなりえる一方、検証やQuery Stringとの連携など複雑な要素が多く、実装者によって大きな差が生じやすい部分であると感じています。

今回は絞り込みFormの作り方を例に、FormとQuery Stringの連携方法や、ユーザーが自由に入力するFormとQuery Stringの値の扱い方について1つの実装方法を紹介します。

前提

記事中で紹介するコードは以下のツールの使用を想定していますが、SPAであれば他のツールでも活用できると考えています。

以下のライブラリを想定したコードも一部使用しますが、コードを簡潔に記述するものであり主旨とは関係ありません。

伝えたいこと

  • Form、Query Stringでユーザーによって指定される値の扱い方
  • FormとQuery Stringを併用する際の実装方法

作るもの

以下の画像のような絞り込みFormと一覧部分で構成される画面を作成します。
絞り込み条件やページネーションはQuery Stringと連動します。
取引日はFrom、Toともに必須項目とします。

ユースケース

以下の2つのシナリオを想定します。

Formに絞り込み条件を入力して絞り込みボタンを押す

  • Formの値を検証する
    • 不正な場合、エラーを表示する
  • 検証が成功した場合、Query Stringへ反映する
  • Query Stringに反映されたForm入力値をパラメータとして、一覧取得APIを呼び出して一覧を表示する

URLを直接開いた場合

  • Query Stringを検証する
    • 不正な場合、Query Stringをあらかじめ決めたデフォルト値で置き換える
  • 検証が成功した場合
    • Query Stringの内容をFormに反映する
  • Query Stringをパラメータとして一覧取得APIを呼び出して一覧を表示する

今回は上記仕様としますがURLを直接開いた場合、APIを呼ばずにFormへセットするだけにするなど要件に合わせて変更できます。

処理の流れ

Form

以下を考慮する必要があります

  • 入力コンポーネントの型に依存した入力値を扱えること
  • デフォルト値を扱えること
  • 入力値の検証が行えること
  • 検証後、後続処理で扱いやすい任意の型で扱うことができること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormInput = {
  transfer_date_from: Date | null
  transfer_date_to: Date | null
  amount_from: string
  amount_to: string
}

日付入力コンポーネントは制御コンポーネントDate | null を扱い、数値入力コンポーネントは非制御コンポーネントstring を扱うことを想定します。

必須項目である取引日は最終的にはnullは許容しませんが、デフォルト値やユーザーによる未入力状態への変更を考慮する必要があるため nullable を指定します。

任意項目である金額はoptionalを付与する必要がありますが、必ずdefaultValuesを指定する設計にすることで、未設定 = 空文字となるためoptionalを付与する必要はありません。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const FORM_SCHEMA = z.object({
  transfer_date_from: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  transfer_date_to: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  amount_from: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
  amount_to: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
})
import { z } from 'zod'

z.input<typeof FORM_SCHEMA>
// {
//   transfer_date_from: Date | null;
//   transfer_date_to: Date | null;
//   amount_from: string;
//   amount_to: string;
// }

z.output<typeof FORM_SCHEMA>
// {
//   transfer_date_from: string;
//   transfer_date_to: string;
//   amount_from?: number;
//   amount_to?: number;
// }

先述した入力を想定した型、出力を想定した型を考慮してスキーマを定義します。 ZodEffects (transformなど) が発生するスキーマは検証前の入力値と検証後の出力値で得られる型が異なります。 z.inputを使用することで入力値の型、z.outputを使用することで出力値の型を得られます。 これによりstring型で入力された値を、検証後はnumber型で扱うようなことが可能になります。

またpipeを使用することで stringで受け付けた値をnumberとして検証することが可能になります。

z.string().positive() // Property 'positive' does not exist on type 'ZodString'
z.string().pipe(z.number().positive()) // OK

defaultValues

上記スキーマの説明においても、必ずdefaultValuesを指定する設計について書きましたが、React Hook FormのuseFormを使用してFormコンポーネントを作成する場合、defaultValuesを必ず設定することをおすすめします。

非制御コンポーネントを使用した場合に、UI上差が無いにもかかわらず、undefinedと空文字をそれぞれ扱わないといけなくなるなど、予想が難しいふるまいをスキーマで表現しないといけなくなります。

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
}

また、defaultValuesとvaluesを併用することは避けることをおすすめします。

今回のサンプルからは外れますが、既存のデータを更新するFormについて考えます。

更新Form

defaultValuesに対し、valuesはリアクティブにFormへ反映されるため、既存データを取得してFormへ反映させる場合に扱いやすいです。

同一項目をもった新規作成Formと更新Formを共通化することも容易にできます。

しかし、以下の理由からvaluesを使用せずにdefaultValuesのみを使用することをおすすめします。

  • defaultValuesを使用してFormが描画される → ユーザーの入力を受け付ける → APIから既存データの取得が完了しFormに反映される → ユーザーが入力した情報が失われる

APIから必要なデータの取得が完了したあと(defaultValuesが確定したあと)、Formコンポーネントをマウントするとよいでしょう。

取得が完了するまでスケルトンを表示するなど、取得中のUIの工夫が必要です。

データ取得完了前にどうしてもFormを表示しておきたい場合は、ローディングなどを表示しユーザー操作を受け付けないようにした方が良いかもしれません。

export const BaseForm = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA>
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
  return <form>{...}</form>
}

const UpdateForm = () => {
  const { data } = useFetchData()
  
  // データ取得が完了してからマウントする
  return data ? <BaseForm defaultValues={{
    transfer_date_from: new Date(data.transfer_date_from),
    transfer_date_to: new Date(data.transfer_date_to)
    amount_from: `${data.amount_from}`,
    amount_to: `${data.amount_to}`,
  }} /> : <p>{'loading...'}</p>
}

話が脱線しましたので元に戻します。

Formの内容をQuery Stringへ反映する

Form Submitをトリガーに入力値を検証し、不正な場合はエラーメッセージを表示するなど適切な処理をします。

正常な場合は、Query Stringへ反映します。

処理の流れに記載した通り、この値を直接APIのパラメータとしては使用しません。

import { useRouter } from 'next/router'

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  const router = useRouter()
  const { handleSubmit } = useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    resolver: zodResolver(formSchema),
    defaultValues,
  })
  
  const onSubmit = (d: z.output<typeof FORM_SCHEMA>) => {
      router.push({ query: { ...d } })
  }
  
  return <form onSubmit={handleSubmit(onSubmit)}>{...}</form>
}

Query String

Formの値をQuery Stringに反映できたので、次はQuery Stringについて見ていきます。

以下を考慮する必要があります

  • Query Stringを扱うライブラリから取得できるQuery Stringの型 (next/router の場合、 useRouter().query の型 string | string[] | undefined ) に依存した入力値を扱えること
  • 値の検証を行えること
  • 検証後は後続処理で扱いやすい任意の型で扱うことができること
  • Query Stringの値をFormに反映できること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringInput = {
  transfer_date_from: string | string[]
  transfer_date_to: string | string[]
  amount_from?: string | string[]
  amount_to?: string | string[]
}

すべての項目が string | string[] | undefined となりますが、要件が許す場合は必須項目のundefinedを許可しない方が扱いやすいと思います。

今回は必須項目の undefined を許可しない形とします。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const isString = (v: unknown): v is string => {
  return typeof v === 'string'
}

const QUERY_STRING_SCHEMA = z.object({
  transfer_date_from: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      // string型以外の場合、z.NEVERを返すことで後続処理ではstring型に絞り込まれる (z.NEVERを返す代わりにthrowしても同様の結果が得られる)
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  transfer_date_to: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  amount_from: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
  amount_to: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
})
z.input<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string | string[]
//   transfer_date_to: string | string[]
//   amount_from?: string | string[]
//   amount_to?: string | string[]
// }

z.output<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string
//   transfer_date_to: string
//   amount_from?: number
//   amount_to?: number
// }

Query Stringの変更

Query Stringが変更された場合(もしくは直接URLを開いた場合)、上記スキーマを使用して検証します。

検証

検証結果が不正な場合は、デフォルト値をセットすることで常に正しい値パラメータを使用できる状態にします。(この例ではQuery Stringの一部でも不正な場合、すべての項目に対してデフォルト値が設定されるため要件に合わせて変更してください。)

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
}, [
  router,query, router.push
]

Query StringをFormに反映

Query StringをFormに反映します。

URLを直接開いた場合、Query StringをFormへ反映する必要があります。

Form Submitした場合も同じ処理が実行されますが、Formの検証を先に行っているためUI上変化はないはずです。(途中のformat等の処理によりForm内容が置換される可能性があるが、大きな問題はないと思っています。ただし、Form Submitせずに入力値をリアルタイムでQuery Stringに反映させていく場合は、この設計をそのまま使うことはできないかもしれません。)

const { reset } = useForm({})

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
  
  reset({
    transfer_date_from: new Date(res.data.transfer_date_from),
    transfer_date_to: new Date(res.data.transfer_date_to),
    amount_from: res.data.amount_from === undefined ? '' : `${res.data.amount_from}`,
    amount_to: res.data.amount_from === undefined ? '' : `${res.data.amount_to}`,
  })
}, [
  router,query, router.push
]

検証後のパラメータを使用してAPIを呼び出し

import { useQuery, skipToken } from '@tanstack/react-query'
import { stringify } from 'qs'

const params = useMemo(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...router.query,
  })

  if (!res.success) {
      return null
    }
    return res.data
}, [router.query])

const { data } = useQuery({
  queryKey: ['transactions', params],
  queryFn: params
    ? () => fetch(`https://api.example.com/transactions?${stringify(params)}`)
    : skipToken,
})

まとめ

以上の設計により、FormからSubmitされた場合もURLを直接開いた場合も、常に「URL → API呼び出し」の流れが確立され、処理の流れがわかりやすくなります。

指摘や他に良い設計があれば是非教えて下さい。