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

2022/02/16に公開

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

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

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

―― 将棋棋士 藤井猛『四間飛車を指しこなす本〈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 の引数として必要である。
その他、1回定義すると、便利なので、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