🐙

Next.js×Firestoreで重複検知を持った事前登録フォームを作ってみた

2023/02/26に公開

この記事について

アルバイト先で NFT マーケットプレイスの事前登録サイトを 1 から全て担当させていただく機会があり、そのときに得た知見をこの場を借りて共有したいと思います 👍

この記事は、Cloud Firestoreと Next.js を使って、事前登録サイトのように重複検知が必要なフォームの作成方法についてまとめたものです。
フォーム作成には、react-hook-formzodを使っています。

本記事内では簡略化のために各要素の style や、非同期処理のローディング処理を実装していません。もし気になる方はそれらを含めた実装例を公開しているので、ご参照いただけますと幸いです。

https://github.com/otacleT/nextjs-firestore-formApp

それでは見ていきましょう 🎉

環境構築

はじめに、Create Next Appを使って Next.js の環境構築を行います。--typescriptのオプションをつけることで、TypeScript が含まれた状態で Next.js アプリケーションを構築することができます。

Next.jsアプリケーションを構築
$> yarn create next-app --typescript

次に、作成されたディレクトリ内で今回使用する package のインストールを行います。

使用するpackageのインストール
$> yarn add react-hook-form @hookform/resolvers zod

ここで簡単にですが、以下にインストールした package の説明を書いておきます。

  • react-hook-form:
    React 向けのフォーム管理ライブラリ
  • @hookform/resolvers:
    YupZod等を使用したスキーマベースのフォーム検証を可能にするもの。useFormにスキーマを渡す際に使う。(公式ドキュメント
  • zod:
    TypeScript first なバリデーションライブラリ。スキーマベースのフォーム検証の際に使う。
    Zod の使い方については以下の記事がとても分かりやすかったです 👇

https://zenn.dev/uttk/articles/bd264fa884e026

🤔 なぜ React Hook Form にバリデーションライブラリを組み合わせるのか

React Hook Form の公式では以下のように掲げられており、React Hook Form でもバリデーションを実装することができます。

高性能で柔軟かつ拡張可能な使いやすいフォームバリデーションライブラリ

しかし、React Hook Form で複雑なバリデーションを実現しようとすると、コンポーネントからバリデーションロジックを切り出すことが難しく以下の問題が生じます。

  • バリデーションの管理・把握が難しい
  • バリデーションロジックの使い回しができない
  • テストを書くことが難しい

そのため、ZodYupなどのバリデーションライブラリを使ってスキーマベースのバリデーションを行うことで、これらの問題を解決することができます ✨

Firebase 環境構築

Cloud Firestore の利用に必要な Firebase SDK のインストールを行います。

Firebase SDKのインストール
$> yarn add firebase

次に、Firebase を初期化し、Cloud Firestore の利用を開始するための処理を追加します。

src/lib/firebase/init.ts
import {getApps, initializeApp} from 'firebase/app'
import {getFirestore} from 'firebase/firestore'

/**
 * @note 管理画面から取得したFirebaseのAPIオブジェクトを`.env.local`に記述しています
 */
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
}

/**
 * @description Firebase instanceの初期化
 */
if (!getApps()?.length) {
  initializeApp(firebaseConfig)
}

/**
 * @description Firestoreを返す
 */
export const db = getFirestore()

これで環境構築は全て完了です 👏

react-hook-form と zod で事前登録フォーム実装

今回作成する事前登録フォームでは、入力値としてメールアドレスとウォレットアドレスを想定しており、それぞれが満たすべき条件は以下のようになっています。

各入力値の条件

  • メールアドレス
    • 必須項目である
    • メールアドレスとして正しい形式になっている
  • ウォレットアドレス
    • 必須項目である
    • 0xから始まる文字列である
    • 42 文字の文字列である

このことを踏まえて、以下のようなバリデーションを含んだフォームを作成しました。
現時点では入力された値を console に表示させる処理になっているので、ロジック部分が完成した後に更新していきます 👍

src/pages/index.tsx
import {zodResolver} from '@hookform/resolvers/zod'
import type {NextPage} from 'next'
import {useCallback} from 'react'
import {useForm} from 'react-hook-form'
import {z} from 'zod'

const regex = /^0x/

const schema = z.object({
  email: z
    .string()
    .min(1, {message: 'メールアドレスを入力してください。'})
    .email('メールアドレスが正しくありません。'),
  walletAddress: z
    .string()
    .min(1, {message: 'ウォレットアドレスを入力してください。'})
    .regex(regex, {
      message: 'ウォレットアドレスは0xから始まる必要があります。',
    })
    .length(42, {
      message: 'ウォレットアドレスは42文字である必要があります。',
    }),
})

const Home: NextPage = () => {
  const {
    formState: {errors},
    handleSubmit,
    register,
  } = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
  })
  const onSubmit = useCallback((data: z.infer<typeof schema>) => {
    console.info(data)
  }, [])
  return (
    <main>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input placeholder='メールアドレスをご入力ください' type='email' {...register('email')} />
        <p>{errors.email?.message}</p>
        <input
          placeholder='ウォレットアドレス(0x..)をご入力ください'
          type='text'
          {...register('walletAddress')}
        />
        <p>{errors.walletAddress?.message}</p>
        <button type='submit'>登録する</button>
      </form>
    </main>
  )
}

export default Home

React Hook Form 公式ドキュメントとほとんど変わらない実装なのですが、React Hook Form にバリデーションライブラリを使用したフォーム作成の流れを簡単に説明すると以下のようになります。

  1. バリデーションライブラリを使用して schema を作成する
  2. 先程インストールした@hookform/resolversを用いてuseFormに schema を渡す

Firestore にデータを追加する処理を実装

ここからロジック部分の実装を行っていきます。
まず、Firestore へデータを追加する処理は以下のようになります。

src/hook/useRegister.ts
import {addDoc, collection} from 'firebase/firestore'
import {useCallback} from 'react'

import {db} from '@/lib/firebase/init'
import type {UserInfo} from '@/type/UserInfo'

export const useRegister = () => {
  const registerUser = useCallback(async (info: UserInfo) => {
    await addDoc(collection(db, `users/`), {
      email: info.email,
      walletAddress: info.walletAddress,
    }).catch((error) => {
      throw new Error(error)
    })
  }, [])

  return {registerUser}
}

💡 Firestore へデータを追加する 2 つの方法

  • setDoc():作成するドキュメントの ID を指定する方法
await setDoc(doc(db, "users", "new-user-id"), data);
  • addDoc():Firestore が ID を自動的に生成してくれる方法
await addDoc(collection(db, "cities"), data);

今回作成した登録フォームでは、自動的に ID を生成する方法が適していたためaddDoc()を使った方法で実装しました。Firestore へデータを追加する方法の詳細については、Firebase 公式ドキュメントを参照してください。

Firestore に既に登録済みの値を検知する処理を実装

次に、Firestore に既に登録済みの値を検知する処理は以下のようになります。

src/hook/useCheckRegistered.ts
import {collection, getDocs, query, where} from 'firebase/firestore'
import {useCallback} from 'react'

import {db} from '@/lib/firebase/init'
import type {UserInfo} from '@/type/UserInfo'

export const useCheckRegistered = () => {
  const checkRegistered = useCallback(async ({email, walletAddress}: UserInfo) => {
    // usersコレクションへのreferenceを作成
    const usersRef = collection(db, 'users')

    // usersコレクションに対するクエリを作成
    const qEmail = query(usersRef, where('email', '==', email))
    const qWalletAddress = query(usersRef, where('walletAddress', '==', walletAddress))

    // クエリを実行し、結果を取得
    const isEmail = await getDocs(qEmail)
    const isWalletAddress = await getDocs(qWalletAddress)

    // isEmail, isWalletAddressが空であるかどうかで、登録済みかどうかを判定
    return {email: !!isEmail.docs.length, walletAddress: !!isWalletAddress.docs.length}
  }, [])

  return {checkRegistered}
}

Firestore では、コレクションまたはコレクショングループから取得するデータをクエリによって指定することができるため、既に登録済みの値であるかは以下のような流れで実装を行いました。

  1. users コレクションへの reference を作成
  2. users コレクションに対するemailwalletAddressのクエリを作成
  3. getDocs()を実行して、作成したクエリに対するデータを取得
  4. 取得したデータが空であるかどうかで、既に Firestore へ登録済みの値であるかどうかを判定

Firestore におけるデータのクエリについて詳しく知りたい方は、公式ドキュメントを参照していただけますと幸いです。👇
https://cloud.google.com/firestore/docs/query-data/queries?hl=ja

事前登録フォームを完成させる

ロジック部分が完成したので、react-hook-form と zod で作成したフォームを以下のように更新します。

src/pages/index.tsx
...
+  const onSubmit = useCallback(
+    async (data: UserInfo) => {
+      // 入力された値が既に登録済みかどうかを判定する
+      const isRegistered = await checkRegistered(data).catch(() => {
+        alert('問題が発生しました。時間をおいて再度お試しください。')
+      })
+
+      if (isRegistered?.email && isRegistered?.walletAddress) {
+        // 両方登録済みの場合
+        alert('このメールアドレスとウォレットアドレスは既に登録されています。')
+      } else if (isRegistered?.email) {
+        // メールアドレスのみ登録済みの場合
+        alert('このメールアドレスは既に登録されています。')
+      } else if (isRegistered?.walletAddress) {
+        // ウォレットアドレスのみ登録済みの場合
+        alert('このウォレットアドレスは既に登録されています。')
+      } else {
+        // どちらも未登録の場合
+        await registerUser(data)
+          .then(() => {
+            console.info('登録完了')
+          })
+          .catch(() => {
+            alert('問題が発生しました。時間をおいて再度お試しください。')
+          })
+      }
+    },
+    [registerUser, checkRegistered]
+  )
-  const onSubmit = useCallback((data: UserInfo) => {
-    console.info(data)
-  }, [])
...

実装完了 ✨

以上で重複検知を持った事前登録フォームが完成です...!

フォームに入力された値が未登録の場合は Firebase へデータが追加され、そうでない場合ははじかれるようになっていると思います 🙌

お疲れさまでした!!!

GitHubで編集を提案

Discussion