🍑

Next.js(SG) + SWR + Recoil + TypeScript でAPIグルメ検索(自動更新機能付き)

2022/03/02に公開

私はこうして文章を書いていますが、去年書いた文章はすべて不満であり、いま書いている文章も、また来年見れば不満でありましょう。それが進歩の証拠だと思うなら楽天的な話であって、不満のうちに停滞し、不満のうちに退歩することもあるのは、自分の顔が見えない人間の宿命でもあります。自分の文章の好みもさまざまに変化して行きますが、かならずしも悪い好みから良い好みに変化してゆくとも言いきれません。それでもなおかつ現在の自分自身にとって一番納得のゆく文章を書くことが大切なのであります。

―― 三島由紀夫『文章読本-新装版』 (中公文庫) p.195

制作したもの。

API を用い、グルメ検索ページを作ります。
使用する API は、リクルート社のホットペッパーグルメ グルメサーチ API です[1]

主な技術構成は、

  • Next.js / Static Generation
  • SWR[2]
  • Recoil
  • TypeScript

です。

以下が全体のコードです。

https://github.com/mumei-xxxx/nextjs-sg-use-swr-gourmet-search-0

仕様について

ファーストビューでは、東京の店舗の一覧が表示されます。
また、一覧に表示される店舗は、30秒ごとに最新のデータに更新するものとします。
ユーザーがフォームにキーワードを入力して、「click」を押下すると、関連する店舗が表示されます。


「渋谷」と入力すれば、「渋谷」の検索結果が、「刺身」と入力すれば「刺身」の検索結果が表示される。

前置き

前回の記事『【 Next.js 】Static Generation + useSWR で、データ更新、最新のデータ表示を可能にする。』の別バリエーションです。

https://zenn.dev/purenium/articles/nextjs-sg-use-swr-strong-consistency

実は、前回の記事で、今回の API グルメ検索を題材にしようと思っていました。
しかし、「Static Generation でデータを更新する、最新のデータを表示する、データの強整合性(Strong Consistency)」というテーマの性質上、数秒でデータが更新される Bitcoin/日本円レート API のほうが、題材とテーマが適合していました。

数秒でデータが変化するため、データが更新されたことがわかりやすいからです。
結果、Bitcoin/日本円レート表示するという題材を選択しました。
しかし、こちらの API グルメ検索もせっかく作ったのにお蔵入りさせるのももったいなく、また、Recoil や検索など、前回の記事にない要素もあるため、記録として残そうと思います。

根幹にある、『【 Next.js 】Static Generation + useSWR で、データ更新、最新のデータ表示を可能にする。』の基本的なしくみについては、前回の記事をご参照いただければ幸いです。

コンセプト

前回の記事と同様、

  • SWR で Next.js の Static Generation を利用しつつ、
    • 可能な限り、常に最新のデータを表示できるようにすること
      • => データの強整合性(Strong Consistency)
    • クライアント側で、表示(されているデータ)を変更できるようにすること
    • 高いパフォーマンス(Google Lighthouse 等で定量的に評価されます)

です。

前回より工夫した点は、以下となります。

  • 一覧ページはクライアント側で、定期的にデータフェッチして、画面を更新します。
    • これには、SWR の自動再検証を使います。
  • 検索して、データを更新し、表示を変更にすることを可能にします。
  • ユーザーが入力するキーワードを Recoil で状態管理。

SWR の定期的な再検証(refreshInterval)について。
例えば、ニュースサイトや前回の Bitcoin/日本円レート API 機能などは、最新のデータが重要です。

  • リロードせずとも、自動で常に最新のデータに表示が切り替わっていてほしい

との仕様上の要求があるかもしれません。
SWR の定期的な再検証(refreshInterval)はそれを可能にします。

前回の記事でも、書きましたが、
特に、Static Generation が、Static Site Generation(SSG)と呼ばれていた時代、更新が多いページは、SSG には適さないと言われていました。
それが、今では、SWR を使えば、定期的に、自動で最新のデータに更新するということも容易にできます。
隔世の感があります。

Google Lighthouse での結果

Google Lighthouse での結果は以下のようになりました。
前回の記事に引き続き、高いパフォーマンスを実現できました。

コンセプト実現のための実装の概要

ユーザーが最初に画面を表示したとき

  • まず、サーバー側で、Next.js の getStaticProps が API のデータを取得します。
  • サーバー側でそのデータをもとに、静的な HTML を生成します。
  • また、そのデータを useSWR の初期値として保持します。
  • ユーザーがブラウザでページを表示するしたとき、クライアント側で、useSWR が API から、データフェッチします。
  • useSWR が API から、取得した最新のデータが画面に表示されます。
  • (前回と違う点) SWR の refreshInterval オプションを利用し、定期的に画面の表示を最新のデータで更新します。

キーワード検索機能

  • Recoil でユーザーが入力するキーワードを定義します。
  • また、Recoil で定義したキーワードを、データフェッチを行う API ルートのパラメーターにします。
  • ユーザーがキーワードを入力し、click ボタンを押します。
    • Recoil の キーワードの状態が更新されます。
  • API ルートのパラメーターが変化し、新規にデータフェッチが行われます。

これは実際に実装を見ていただくほうが理解がしやすいかもしれません。
また、私が実際にコードをいじりながら挙動を確認しましたところ、SWR で、バウンドミューテーションなどを行わなくても、キー(API の URL)が変化すると、新規にデータフェッチが行われるようです。

バージョン情報

Node.js 16.11.0

"next": "^12.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"recoil": "^0.5.2",
"sass": "^1.49.7",
"swr": "^1.2.1",
"xml-js": "^1.6.11"

ディレクトリ構成

.
├── .env
├── LICENSE
├── next.config.js
├── next-env.d.ts
├── package.json
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── README.md
├── src
│   ├── components
│   │   └── SearchForm.tsx
│   ├── infrastructure
│   │   ├── hooks
│   │   │   └── useShopDataSWR.ts
│   │   └── recoil
│   │       └── useUserInputKeywordState.ts
│   ├── libraries
│   │   └── fetcher.ts
│   ├── pages
│   │   ├── api
│   │   │   └── gourmet
│   │   │       ├── index.ts
│   │   │       └── [keyword].ts
│   │   ├── _app.tsx
│   │   └── index.tsx
│   ├── styles
│   │   ├── globals.css
│   │   └── Home.module.scss
│   └── @types
│       └── global.d.ts
├── tsconfig.json
├── yarn-error.log
└── yarn.lock

実装

ホットペッパーグルメ グルメサーチAPIについて

今回の記事の執筆にあたり、以下の記事を参考にしました。

【Next.js】Next.js+tailwind cssでシンプルなグルメ店検索アプリを作ってみた! - Qiita

https://qiita.com/dtakkiy/items/490a2a2ead301474edc6

ホットペッパーグルメ API の詳細な使い方の解説は、上記の記事に丁寧な解説があります。
この API は、API キーの事前の取得が必要です。

今回は、ルートの .env

.env
API_URL_ROOT=http://webservice.recruit.co.jp/hotpepper/gourmet/v1/?format=json&key=《取得したAPIキー》&large_area=Z011

のような形で、API の URL を定義しました。
process.env.API_URL_ROOT でコード内で取得できるようにします。
(また、large_area=Z011 というのは、東京エリアという意味です。)

型情報

型情報については、ホットペッパーグルメのドキュメントを参考にしました。[1:1]
src/@types/global.d.ts に定義しました。

src/@types/global.d.ts
/**
 * @description API仕様
 * ホットペッパー | APIリファレンス | リクルートWEBサービス
 * https://webservice.recruit.co.jp/doc/hotpepper/reference.html
 */

interface ShopObj {
  id: string
  name: string
  station_name: string
  genre: {
    name: string
    catch: string
  }
}

interface HotpepperResponseType {
  results: {
    shop: ShopObj[]
  }
}

/**
 * @description エラー時のレスポンス
 * https://webservice.recruit.co.jp/doc/hotpepper/reference.html
 * 13.エラー時のレスポンス
 */
interface HotpepperErrorResponseType {
  results: {
    error: {
      message: string
      code: string
    }
  }
}

ルートコンポーネント src/pages/index.tsx

それでは、ルートコンポーネントを、見ていきましょう。

src/pages/index.tsx
import { GetStaticProps } from 'next'
import Head from 'next/head'
import Image from 'next/image'
import React from 'react'

import { SearchForm } from '@/components/SearchForm'
import { useShopDataSWR } from '@/infrastructure/hooks/useShopDataSWR'
import { useUserInputKeywordState } from '@/infrastructure/recoil/useUserInputKeywordState'
import { fetcher } from '@/libraries/fetcher'
import styles from '@/styles/Home.module.scss'
interface Props {
  fallbackData: HotpepperResponseType
}

/**
 * @description ルートコンポーネント
 * 参考:
 * useSWR サンプル server-render
 * https://github.com/vercel/swr/blob/main/examples/server-render/pages/index.js
 */
const Home: React.FC<Props> = ({ fallbackData }) => {
  // Recoil を hook化
  // ユーザーが入力したキーワード
  const userSetKeyword: string = useUserInputKeywordState()
  // useSWR を hook化
  // getStaticProps からの fallbackDataを初期値に持つ。
  // クライアント側でのデータフェッチを行う。
  const { data } = useShopDataSWR(userSetKeyword, fallbackData)

  return (
    <div>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <header>
        <div className={styles.headerContenter}>
          <h1 className={styles.title}>東京グルメ検索(ホットペッパーAPI)</h1>
          <SearchForm userSetKeyword={`${userSetKeyword}`} fallbackData={fallbackData} />
        </div>
      </header>
      <main>
        <div className={styles.container}>
          <div className={styles.shopList}>
            {data?.results ? (
              data.results.shop.map((shopData: ShopObj) => {
                return (
                  <div key={shopData.id} className={styles.shopData}>
                    <p>
                      <span className={styles.shopDataTitle}>掲載店名:</span>
                      <br />
                      {shopData.name}
                    </p>
                    <p>
                      <span className={styles.shopDataTitle}>最寄駅名:</span>
                      <br />
                      {shopData.station_name}
                    </p>
                    <p>
                      <span className={styles.shopDataTitle}>お店ジャンル:</span>
                      <br />
                      {shopData.genre.name}
                    </p>
                    <p>
                      <span className={styles.shopDataTitle}>お店ジャンルキャッチ:</span>
                      <br />
                      {shopData.genre.catch}
                    </p>
                  </div>
                )
              })
            ) : (
              <p>loading……</p>
            )}
          </div>
        </div>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <span className={styles.logo}>
            <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
          </span>
        </a>
      </footer>
    </div>
  )
}

// eslint-disable-next-line import/no-default-export
export default Home

export const getStaticProps: GetStaticProps = async () => {
  if (typeof process.env.API_URL_ROOT === 'undefined') {
    return {
      props: {
        fallbackData: undefined
      }
    }
  }

  const API_URL = process.env.API_URL_ROOT

  const data = await fetcher(API_URL)
  return {
    props: {
      fallbackData: data
    }
  }
}

初期表示

初期表示の根本的な原理は、前回の記事で解説したものと同じです。

初めに、サーバー側で getStaticProps でデータを取得します。
そのデータを SWR のfallbackData に格納し、事前キャッシュとして持っています。

検索部分は、SearchForm コンポーネントとして別のコンポーネントに分けています。

Recoil で 検索キーワードを管理

前回との違いは、ユーザーが入力したキーワードの要素が加わっているところです。

具体的には、

src/pages/index.tsx
const userSetKeyword: string = useUserInputKeywordState()

の部分です。
これは、よしこ様の記事 『「3種類」で管理するReactのState戦略』 を参考に、Recoil の useRecoilValueを hook 化しました。

https://zenn.dev/yoshiko/articles/607ec0c9b0408d

コードは、以下のようになっています。

src/infrastructure/recoil/useUserInputKeywordState.ts
import React from 'react'
import { atom, SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'

/**
 * @description
 * 参考:
 * 「3種類」で管理するReactのState戦略
 * https://zenn.dev/yoshiko/articles/607ec0c9b0408d#recoil%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9Fglobal-state%E3%81%AE%E7%AE%A1%E7%90%86
 */

/**
 * @description ユーザーが入力したキーワードを定義するAtom
 */
export const userInputKeywordState = atom<string>({
  key: 'UserInputKeyword',
  default: ''
})

/**
 * @description ユーザーが入力したキーワード
 */
export const useUserInputKeywordState = (): string => {
  return useRecoilValue(userInputKeywordState)
}

interface UseUserInputKeywordMutatorType {
  setSearchKeyword: (x: string) => void
}

/**
 * @description ユーザーが入力したキーワードをセットする関数
 */
export const useUserInputKeywordMutator = (): UseUserInputKeywordMutatorType => {
  const setState: SetterOrUpdater<string> = useSetRecoilState(userInputKeywordState)
  const setSearchKeyword = React.useCallback(
    (x: string) => {
      setState(x)
    },
    [setState]
  )

  return { setSearchKeyword }
}

hook 化することで、Recoil がカプセル化されます。
useRecoilValueuseSetRecoilState などを、利用するファイルごとにインポートする手間が省けます。

Recoil 設定のための _app.tsx の内容は、以下です。

src/pages/_app.tsx
src/pages/_app.tsx
import type { AppProps /*, AppContext */ } from 'next/app'
import { RecoilRoot } from 'recoil'

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
    <RecoilRoot>
      <Component {...pageProps} />
    </RecoilRoot>
  )
}

// eslint-disable-next-line import/no-default-export
export default MyApp

SWR のカスタムフック

SWR 部分も、前回の記事と同様に、
hook 化しています。

src/infrastructure/hooks/useShopDataSWR.ts
import useSWR, { SWRResponse } from 'swr'

import { fetcher } from '@/libraries/fetcher'

/**
 * @description
 * 参考にしたコード
 * useSWR api-hooks サンプル
 * https://github.com/vercel/swr/tree/main/examples/api-hooks
 * SWR 定期的な再検証
 * https://swr.vercel.app/ja/docs/revalidation
 */
export const useShopDataSWR = (
  userSetKeyword: string,
  fallbackData: HotpepperResponseType
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): SWRResponse<HotpepperResponseType, any> => {
  // 30秒ごとに自動更新。
  return useSWR(`api/gourmet/${userSetKeyword}`, fetcher, { fallbackData, refreshInterval: 30000 })
}
src/infrastructure/hooks/useShopDataSWR.ts
return useSWR(`api/gourmet/${userSetKeyword}`, fetcher, { fallbackData, refreshInterval: 30000 })

  • 第一引数 api/gourmet/${userSetKeyword}
  • 第二引数 fetcher
  • 第三引数 { fallbackData, refreshInterval: 30000 }

について、見ていきます。

第一引数 api/gourmet/${userSetKeyword} について。

前回の記事との違いは、

  • ユーザーが入力したキーワード(userSetKeyword)を引数にとる。

ことです。
userSetKeyword が変わると、全体として、キー api/gourmet/${userSetKeyword} が変わるのため、
新規にデータをフェッチ(再検証)が行われるようです。

第二引数 fetcherについて

前回の記事に解説を譲ります。

fetcher コード(src/libraries/fetcher.ts)
src/libraries/fetcher.ts
/**
 * @description
 * useSWR のパラメータなどで使用する。
 * @param {string} [args] API URL
 * @returns {Promise<HotpepperResponseType>} API レスポンス
 */
export const fetcher = async (args: string): Promise<HotpepperResponseType> => {
  const response = await fetch(args)
  return (await response.json()) as HotpepperResponseType
}
第三引数 { fallbackData, refreshInterval: 30000 }

fallbackData についても、前回の記事に解説を譲ります。
refreshInterval: 30000 という部分は、SWR の refreshInterval オプションで、30秒ごとにデータをフェッチを行うということです。

「定期的な再検証」自動再検証 – SWR

https://swr.vercel.app/ja/docs/revalidation

そして、データが変化している場合は、表示を更新します。
とはいえ、グルメ検索 API はそうそう数秒単位で、データが更新されるわけでありません。
本当にデータが更新されるのかという疑念がわくと思います。
そのため、前回の、Bitcoin/日本円レートのページで、0.5秒ごとの自動更新(refreshInterval: 500)を設定した場合どうなるのか検証しました。
下の gif の通りになりました。
timestamp、売り価格の数字の表示が、刻々と切り替わっているのがお分かりかと思います。

以上で、冒頭でも触れた、自動更新が実現できました。

API Routes

こちらも前回と同じですが、API を直接、クライアント側からたたくと、CORS エラーとなります。
CORS エラーを避けるため、Next.js で 動的 API ルーティングを行っています。
実装は、以下の、公式ドキュメント通りです。

https://nextjs.org/docs/api-routes/dynamic-api-routes
日本語訳:
https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/dynamic-api-routes

  • src/pages/api/gourmet/index.ts
  • src/pages/api/gourmet/[keyword].ts

とふたつファイルを用意します。

src/pages/api/gourmet/[keyword].tsでは、以下のような処理を行っています。
まず、useShopDataSWRuseSWR(`api/gourmet/${userSetKeyword}`,……)
でセットした、userSetKeywordは、

src/pages/api/gourmet/[keyword].ts
const {
  query: { keyword }
} = req

の形で取得します。

そして、ユーザーが入力したキーワードは、encodeURI(keywordString)のようにエンコードしています。
全体のコードは以下です。

src/pages/api/gourmet/[keyword].ts
import type { NextApiRequest, NextApiResponse } from 'next'

import { fetcher } from '@/libraries/fetcher'

const handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
  const {
    query: { keyword }
  } = req

  if (typeof process.env.API_URL_ROOT === 'undefined') return

  const API_URL_ROOT = process.env.API_URL_ROOT

  const keywordString: string = Array.isArray(keyword) ? keyword.join(' ') : keyword

  const API_URL =
    keyword === '' || typeof keyword === 'undefined' || keyword === null
      ? API_URL_ROOT
      : `${API_URL_ROOT}&keyword=${encodeURI(keywordString)}`

  const data = await fetcher(API_URL)
  res.end(JSON.stringify(data))
}

// eslint-disable-next-line import/no-default-export
export default handler
src/pages/api/gourmet/index.ts
src/pages/api/gourmet/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'

import { fetcher } from '@/libraries/fetcher'

const handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
  if (typeof process.env.API_URL_ROOT === 'undefined') return

  const data = await fetcher(process.env.API_URL_ROOT)
  res.end(JSON.stringify(data))
}

// eslint-disable-next-line import/no-default-export
export default handler

検索フォームコンポーネント src/components/SearchForm.tsx

最後に検索フォームコンポーネント(SearchForm)です。

src/components/SearchForm.tsx
import React from 'react'

import { useShopDataSWR } from '@/infrastructure/hooks/useShopDataSWR'
import { useUserInputKeywordMutator } from '@/infrastructure/recoil/useUserInputKeywordState'
import { fetcher } from '@/libraries/fetcher'

interface SearchFormProps {
  userSetKeyword: string
  fallbackData: HotpepperResponseType
}
/**
 * @description 検索フォームコンポーネント
 * useSWR で制御しているデータを更新する。
 */
export const SearchForm: React.FC<SearchFormProps> = ({ userSetKeyword, fallbackData }) => {
  const { setSearchKeyword } = useUserInputKeywordMutator()

  const { mutate } = useShopDataSWR(userSetKeyword, fallbackData)

  const formRef: React.RefObject<HTMLFormElement> = React.useRef<HTMLFormElement>(null)

  const handlerOnSubmitSearch = async (e: React.SyntheticEvent): Promise<void> => {
    e.preventDefault()

    const target = e.target as typeof e.target & {
      seachWord: { value: string }
    }

    // ユーザーが入力したキーワード
    const seachWordValue: string = target.seachWord.value

    // RecoilのsetState
    setSearchKeyword(seachWordValue)

    // バウンドミューテーション
    // フォームに同一キーワードが入っている状態でclickボタンを複数押下したときに、
    // 厳密にデータが最新か検証を行う場合は必要。
    const mutationData = await fetcher(`api/gourmet/${seachWordValue}`)

    mutate(mutationData).catch((error) => {
      throw error
    })
  }

  return (
    <>
      <form ref={formRef} onSubmit={handlerOnSubmitSearch}>
        <input type="search" name="seachWord" placeholder="Enter keyword …" />
        <button>click</button>
      </form>
    </>
  )
}

処理の流れとしては、click ボタンを押下して onSubmit 時に、入力されたキーワードを Recoil の setState によって、更新しています。
setSearchKeyword は、さきほど Recoil 部分のコードにありますが、Recoil の setState を hook 化したものです。
ユーザーが入力したキーワードの state の変化が、trigger となります。
SWR のキー(useSWR の第一引数、動的 API ルート api/gourmet/${userSetKeyword})が変化するためです。
そして、データフェッチが行われます。

SWR のキーが変化すれば、SWR のミューテーションを行わなくても、データフェッチが行われ、検索結果の画面に切り替わります。
しかし、例えば、入力したキーワードに変化がない場合に click ボタンを連続して押下した場合に、厳密にデータの再検証を行いたい場合は、
SWR のミューテーション処理が必要です。

コードで言えば、

src/components/SearchForm.tsx
const mutationData = await fetcher(`api/gourmet/${seachWordValue}`)

mutate(mutationData).catch((error) => {
  throw error
})

の部分です。

感想

以前にも、こういった API で検索するものを制作したことがあります。
その際は、非同期に取得するデータの管理は、Redux Toolkit を使用しました。
今回、SWR を使用することで、非同期に取得するデータの管理を、SWR のキャッシュ共有で行う方法は有効な場合があることを感じました。
Recoil で非同期でない状態管理、SWR で非同期のデータ管理という使い分けが今回はうまく機能したと思っております。
今後もこの方策は利用するかもしれません。

また、単純に、SWR の refreshInterval で画面表示が自動で切り替わっていくのは、すでに界隈では有名なのかもしれませんが、私は感動しました。

参考記事

CSS については、本文でまったく触れませんでしたが、以下を参考にいたしました。

CSS Grid を使ったレスポンシブ対応の基本レイアウト|Web クリエイターボックス https://www.webcreatorbox.com/tech/css-grid-basic-layout @webcreatorbox より

脚注
  1. https://webservice.recruit.co.jp/doc/hotpepper/reference.html ↩︎ ↩︎

  2. https://swr.vercel.app/ja ↩︎

Discussion