【 Next.js 】Static Generation + useSWR で、データ更新、最新のデータ表示を可能にする。

2022/02/16に公開約13,100字

「四間飛車の極意を一言でいうと何でしょう?」

ファンの質問に私はいつもこう答えます。

「相手の力を利用して、投げる、でしょうか。」

―― 将棋棋士 藤井猛『四間飛車を指しこなす本〈1〉』(2000/3/24)河出書房新社

実現したいコンセプト

  • Next.js の Static Generation (略して SG[1]。以前でいうところのSSG(Static Site Generation))を利用し、ハイパフォーマンスなページを作りたい。
    • (Google Lighthouse で100点を取りたい。)
  • Static Generation を利用しつつ、ページのデータを更新したい。
    • 特に、SSG と呼ばれていた時代、更新が多いページは、SSG には適さないと言われていた。
  • ページを表示したとき、最新のデータが表示されるようにしたい。
    • => データの強整合性(Strong Consistency)を実現をしたい。

制作したもの / Google Lighthouse での結果

上記を実現するために、制作したコードはこちらになる。

https://github.com/mumei-xxxx/nextjs-sg-use-swr-strong-consistency-0

題材として、今回は、最新のBitcoin/日本円情報を表示するページを作成した。
内容は、1 Bitcoin の日本円での売り価格が500万いくらか(執筆時のレート)というものだ。
数秒で価格が変動し、ページが更新されたかがわかりやすため、この題材を選択した。
データフェッチは、bitflyer の HTTP Public API[2]から行う。
(※ こちらの bitflyer の API は、呼び出し制限(同じIPアドレスで5分間で500回)等があるので注意。)
「Update to the Latest Info」ボタンを押下すると、最新の情報に更新される。
下のgif画像は、データが最新のものに切り替わっている様子を収めたものだ。

Google Chrome の Google Lighthouse での結果は以下。
今回、表示する内容は少ないが、100、97 と全体的に好成績をおさめられた。

課題解決にあたっての、主な使用技術

  • Next.js の getStaticProps
    • Static Generation のため使用
  • SWR
    • データ取得のための React Hooks ライブラリ。useSWR という React Hooks を使用する。
    • 説明が長くなるため、以下、項を改めて説明。

課題の解決 / SWR と useSWR について

SWR 、useSWR が如何にして今回の課題を解決するかを見ていこうと思う。

まず、SWR とは、stale-while-revalidate の略である。
stale-while-revalidate について、SWR公式サイト[3]より引用

“SWR” という名前は、 HTTP RFC 5861 で提唱された HTTP キャッシュ無効化戦略である stale-while-revalidate に由来しています。 SWR は、まずキャッシュからデータを返し(stale)、次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。

useSWR の一面として、データフェッチを行うライブラリである。
ただ、useSWR 単体ではなく、axios のようなライブラリや JavaScript/TypeScript の fetch などと組み合わせて使う[4](後述)。
axios などのライブラリとの違いは、以下のようなデータの取り扱い方にある。

上の引用文で、

SWR は、まずキャッシュからデータを返し(stale)

とある。

今回制作したBitcoinアプリでは、まず、サーバー側で getStaticProps でデータを取得する。
そして、SWRは、getStaticProps で取得したデータを、初期値として持つ(事前にデータをキャッシュする)。

次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。

次に、クライアント側で、useSWR が APIにリクエストを送り、データフェッチを行う。
データは最新のものとなるので、最新のデータがブラウザに表示されるという流れになる。

再度、公式より引用。

SWR では、 コンポーネントはデータの更新を継続的かつ自動的に受け取ることができます。
そして、 UI は常に高速でリアクティブなモノになります。

また、useSWR では、単にキャッシュを保持するだけでなく、データの更新も柔軟に行える。
例えば、ユーザーがタブを切り替えたときや、ユーザーのPCがスリープ状態から復帰したときに、自動でデータを最新のものに更新する。
以上のような、キャッシュの保持、更新機能などの機能が、useSWR が、axiosfetch 等の一般的なデータフェッチライブラリ/関数と異なる点である。

以下、私が参考にしたページを何点か列挙する。

公式ドキュメント日本語訳
データ取得のための React Hooks ライブラリ – SWR
はじめに 例
https://swr.vercel.app/ja/docs/getting-started#例

React.useEffect を使った実装とその実装を useSWR でのリファクタリングした実装を比較した例がわかりやすかった。

公式 GitHub の examples
https://github.com/vercel/swr/tree/main/examples

今回は、「api-hooks」 や 「server-render」の例を参考にした。

Next.jsのISRで動的コンテンツをキャッシュするときの戦略
https://zenn.dev/catnose99/articles/8bed46fb271e44

Zenn の catnose氏の記事。
Next.js の ISR も stale-while-revalidate の考え方に基づいた実装らしい。
図がわかりやすかった。

そうです。わたしがReactをシンプルにするSWRです。
https://zenn.dev/uttk/articles/b3bcbedbc1fd00

バウンドミューテーション等について、示唆をいただいた。

課題解決方法

上で述べたことを再度まとめたい。

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

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

ユーザーが「Update to the Latest Info」ボタン押下でデータを更新。

クライアント側での処理となる。

  • useSWR がAPIから、データフェッチする。
  • useSWRmutate を用いデータを更新する。
  • 取得した最新のデータが画面に表示される。

となる。

バージョン情報

Node.js 16.11.0

"next": "^12.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "^1.49.7",
"swr": "^1.2.1",

"typescript": "^4.4.3"

ディレクトリ構成

.
├── LICENSE
├── next.config.js
├── next-env.d.ts
├── package.json
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── README.md
├── src
│   ├── infrastructure
│   │   └── hooks
│   │       └── useBitcoinDataSWR.ts
│   ├── libraries
│   │   └── fetcher.ts
│   ├── pages
│   │   ├── api
│   │   │   └── bitcoin
│   │   │       └── index.ts
│   │   ├── _app.tsx
│   │   └── index.tsx
│   ├── styles
│   │   ├── globals.css
│   │   └── Home.module.scss
│   └── @types
│       └── global.d.ts
├── tsconfig.json
└── yarn.lock

実装

型情報については、bitflyer の APIリファレンスを参考にし、
src/@types/global.d.ts に定義した。

src/@types/global.d.ts
/**
 * @description API仕様
 * ビットコイン取引所【bitFlyer Lightning】
 * API Documentation
 * https://lightning.bitflyer.com/docs?lang=ja&_gl=1*7z87hf*_ga*MTMwMTY3NzUzMi4xNjQ0OTI3MjM1*_ga_3VYMQNCVSM*MTY0NDkyNzIzNS4xLjEuMTY0NDkyOTA5Mi42MA..#http-public-api
 */
interface BitflyerTickerResponseType {
  product_code: string
  timestamp: string
  best_bid: string
  best_ask: 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 { useBitcoinDataSWR } from '@/infrastructure/hooks/useBitcoinDataSWR'
import { fetcher } from '@/libraries/fetcher'
import styles from '@/styles/Home.module.scss'

interface Props {
  fallbackData: BitflyerTickerResponseType
}

/**
 * @description ルートコンポーネント
 * 参考:
 * useSWR サンプル server-render
 * https://github.com/vercel/swr/blob/main/examples/server-render/pages/index.js
 */
const Home: React.FC<Props> = ({ fallbackData }) => {
  /**
   * @description useSWR のカスタムフック
   * getStaticProps からの fallbackDataを初期値に持つ。
   * クライアント側でのデータフェッチを行う。
   */
  const { data, mutate } = useBitcoinDataSWR(fallbackData)

  const onClickUpdateBtn = async (): Promise<void> => {
    const newData = await fetcher('/api/bitcoin')

    // データを更新(mutate)
    mutate(newData).catch((error) => {
      throw error
    })
  }

  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}>最新のBitcoin情報</h1>
          <p className={styles.hp_tac}>bitflyer HTTP Public API から、最新のBitcoin情報を表示しています。</p>
        </div>
      </header>
      <main>
        <div className={styles.container}>
          {data ? (
            <div>
              <p>product_code: {data.product_code}</p>
              <p>timestamp: {data.timestamp}</p>
              <p>最低売り価格(best bid): {data.best_bid}</p>
              <p>最高売り価格(best ask): {data.best_ask}</p>
            </div>
          ) : (
            // <p>{data.product_code}</p>
            <p>loading……</p>
          )}
          <button onClick={onClickUpdateBtn}>Update to the Latest Info</button>
          <div className={styles.shopList}></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 () => {
  const API_URL_ROOT = 'https://api.bitflyer.com/v1/getticker'

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

初期表示

課題解決方法に書いた説明と重複するがコードベースで説明する。
まず、サーバー側で、getStaticProps でデータを取得する。

それを、fallbackData として Homeコンポーネントに渡す。
fallbackDatauseSWR をhook化した useBitcoinDataSWR に渡す。
これで、useSWR が持つデータの初期値が、getStaticProps で取得したもの(fallbackData)になる。

最新のデータへの更新

関連のある部分だけを抜粋。

(略)
const { data, mutate } = useBitcoinDataSWR(fallbackData)

const onClickUpdateBtn = async (): Promise<void> => {
  const newData = await fetcher('/api/bitcoin')

  // データを更新(mutate)
  mutate(newData).catch((error) => {
    throw error
  })
}
(略)

<button onClick={onClickUpdateBtn}>Update to the Latest Info</button>

(略)

「Update to the Latest Info」ボタン押下で、onClickUpdateBtn が実行される。
onClickUpdateBtn でキャッシュの更新、mutate を行っている。
今回行っているのは、「バウンドミューテート」という手法だ。
APIルートにリクエストを送り、データをフェッチしたのち、セットしている。

ミューテーション 「バウンドミューテート」

https://swr.vercel.app/ja/docs/mutation#バウンドミューテート

useBitcoinDataSWRuseSWR のカスタムフック src/infrastructure/hooks/useBitcoinDataSWR.ts

useShopDataSWR のコードは以下のようになっている。

src/infrastructure/hooks/useBitcoinDataSWR.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
 */
export const useBitcoinDataSWR = (
  fallbackData: BitflyerTickerResponseType
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): SWRResponse<BitflyerTickerResponseType, any> => {
  return useSWR(`api/bitcoin`, fetcher, { fallbackData })
}

src/infrastructure/hooks/useBitcoinDataSWR.ts
return useSWR(`api/bitcoin`, fetcher, { fallbackData })

の部分を解説したい。

  • 第一引数 api/bitcoin はNext.js 側に作った API Routes。
  • 第二引数 fetcher は、フェッチして、レスポンスを返す関数。
  • 第三引数 fallbackData は、useSWR が持つ初期値。

引数(api/bitcoinfetcherfallbackData)をひとつずつ見てみよう。

API Routes

APIを直接、クライアント側からたたくと、CORSエラー[5]となる。
そのため、Next.js で API Routesを作っている。

Next.js API route support:

https://nextjs.org/docs/api-routes/introduction
src/pages/api/bitcoin/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'

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

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

  const API_URL = 'https://api.bitflyer.com/v1/getticker'

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

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

fetcher(src/libraries/fetcher.ts)

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

単純に、fetch でリクエストを送り、レスポンスを返しているだけの関数。
fetcher は、useSWR の引数として必要である。
その他、一回定義すると、便利なので、API Routes でも流用している。

fallbackData

useSWR のオプション。
useSWR に事前にデータをキャッシュできる。
こちらは、昨年命名が変わったらしい。
(旧名称は initialData
今後また命名、仕様が変わるかもしれない。

initialData から fallbackData へ名前を変更
SWR 1.0 の発表 August 27th, 2021 by Shu DingJiachi Liu
https://swr.vercel.app/ja/blog/swr-v1#initialdata-から-fallbackdata-へ名前を変更

fallbackData: 返される初期データ(注:フックごとに)
SWR API オプション
https://swr.vercel.app/ja/docs/options

もし既に存在しているデータを SWR にキャッシュしたい場合は、 fallbackData オプションを使うことができます。
データのプリフェッチ 事前データ
https://swr.vercel.app/ja/docs/prefetching#事前データ

検証

ボタンの押下で、データが最新のものになることは、上記に掲載したgif画像でご確認いただけるかと思う。

では、ユーザーがページを最初に表示したとき、本当に最新のデータが表示されているのだろうか。これを検証したい。

まず、Next.js のビルドを行う。

yarn build

生成されたHTML
.next/server/pages/index.html
を確認すると、
タイムスタンプが、「timestamp: 2022-02-16T05:37:22.123」となっている。

※ 時刻は UTC(協定世界時)

続いて、

yarn start

で、画面を立ち上げ http://localhost:3000/ を見ると、
タイムスタンプが、「timestamp: 2022-02-16T05:39:53.07」となっているので、最新のデータが表示されていることがわかる。

まとめ

getStaticPropsuseSWRfallbackData、ミューテーションという機能を使うことで、

  • ハイパフォーマンスなページ。(Lighthouse で100点等)
  • SG を利用したページのデータ更新。
  • データの強整合性(Strong Consistency)。

が実現できることがわかった。

感想など

今回のアプリケーションを制作するにあたってかなりの紆余曲折があった。
そもそも、最初は、SWRの導入を検討していなかった。
しかし、調べるうちに、どうやら、useSWR を所定の目的が達成できるかもしれないと思った。
useSWR など、サッパリわからない状態から、公式等をみて、今回、紹介したような方式を構想した。
しかし、確認のため、上にも上げた、catnose氏の記事を読んでいると、

Next.jsのISRで動的コンテンツをキャッシュするときの戦略

https://zenn.dev/catnose99/articles/8bed46fb271e44

VercelのUesugi氏が強整合性(Strong Consistency)を実現する手法について、Twitterでつぶやかれていた。

https://twitter.com/chibicode/status/1299500165418479616

なので、本記事は、2 のパターンを実体化したものとなる。

その他

現在、休職して実家で親の介護中です……。
お金が若干厳しいので、Zennのサポート等いただけましたら、とてもありがたいです……

脚注
  1. Basic Features: Pages | Next.js https://nextjs.org/docs/basic-features/pages#two-forms-of-pre-rendering ↩︎

  2. https://lightning.bitflyer.com/docs?lang=ja&_gl=17z87hf_gaMTMwMTY3NzUzMi4xNjQ0OTI3MjM1_ga_3VYMQNCVSM*MTY0NDkyNzIzNS4xLjEuMTY0NDkyOTA5Mi42MA..#http-public-api ↩︎

  3. データ取得のための React Hooks ライブラリ – SWR https://swr.vercel.app/ja ↩︎

  4. データフェッチ – SWR https://swr.vercel.app/ja/docs/data-fetching ↩︎

  5. CORSの原理を知って正しく使おう https://youtu.be/ryztmcFf01Y ↩︎

Discussion

ログインするとコメントできます