【 Next.js 】Static Generation + useSWR で、データ更新、最新のデータ表示を可能にする。
「四間飛車の極意を一言でいうと何でしょう?」
ファンの質問に私はいつもこう答えます。
「相手の力を利用して、投げる、でしょうか。」
―― 将棋棋士 藤井猛『四間飛車を指しこなす本〈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 での結果
上記を実現するために、制作したコードはこちらになる。
題材として、今回は、最新の 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 を使用する。 - 説明が長くなるため、以下、項を改めて説明。
- データ取得のための React Hooks ライブラリ。
useSWR
について
課題の解決 / SWR と 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
が、axios
や fetch
等の一般的なデータフェッチライブラリ/関数と異なる点である。
以下、私が参考にしたページを何点か列挙する。
公式ドキュメント日本語訳
データ取得のための 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 から、データフェッチする。 -
useSWR
のmutate
を用いデータを更新する。 - 取得した最新のデータが画面に表示される。
となる。
バージョン情報
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
に定義した。
/**
* @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
ルートコンポーネント それでは、ルートコンポーネントから、見ていきたいと思う。
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
コンポーネントに渡す。
fallbackData
を useSWR
を 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 ルートにリクエストを送り、データをフェッチしたのち、セットしている。
ミューテーション 「バウンドミューテート」
useBitcoinDataSWR
(useSWR
のカスタムフック src/infrastructure/hooks/useBitcoinDataSWR.ts
)
useShopDataSWR
のコードは以下のようになっている。
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 })
}
return useSWR(`api/bitcoin`, fetcher, { fallbackData })
の部分を解説したい。
- 第一引数
api/bitcoin
は Next.js 側に作った API Routes。 - 第二引数
fetcher
は、フェッチして、レスポンスを返す関数。 - 第三引数
fallbackData
は、useSWR
が持つ初期値。
引数(api/bitcoin
、fetcher
、fallbackData
)をひとつずつ見てみよう。
API Routes
API を直接、クライアント側からたたくと、CORS エラー[5]となる。
そのため、Next.js で API Routes を作っている。
Next.js API route support:
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)
/**
* @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」となっているので、最新のデータが表示されていることがわかる。
まとめ
getStaticProps
、useSWR
の fallbackData
、ミューテーションという機能を使うことで、
- ハイパフォーマンスなページ。(Lighthouse で100点等)
- SG を利用したページのデータ更新。
- データの強整合性(Strong Consistency)。
が実現できることがわかった。
感想など
今回のアプリケーションを制作するにあたってかなりの紆余曲折があった。
そもそも、最初は、SWR の導入を検討していなかった。
しかし、調べるうちに、どうやら、useSWR
を所定の目的が達成できるかもしれないと思った。
useSWR
など、サッパリわからない状態から、公式等をみて、今回、紹介したような方式を構想した。
しかし、確認のため、上にも上げた、catnose 氏の記事を読んでいると、
Next.js の ISR で動的コンテンツをキャッシュするときの戦略
Vercel の Uesugi 氏が強整合性(Strong Consistency)を実現する手法について、Twitter でつぶやかれていた。
なので、本記事は、2 のパターンを実体化したものとなる。
その他
現在、休職して実家で親の介護中です……。
お金が若干厳しいので、Zenn のサポート等いただけましたら、とてもありがたいです……
-
Basic Features: Pages | Next.js https://nextjs.org/docs/basic-features/pages#two-forms-of-pre-rendering ↩︎
-
https://lightning.bitflyer.com/docs?lang=ja&_gl=17z87hf_gaMTMwMTY3NzUzMi4xNjQ0OTI3MjM1_ga_3VYMQNCVSM*MTY0NDkyNzIzNS4xLjEuMTY0NDkyOTA5Mi42MA..#http-public-api ↩︎
-
データ取得のための React Hooks ライブラリ – SWR https://swr.vercel.app/ja ↩︎
-
データフェッチ – SWR https://swr.vercel.app/ja/docs/data-fetching ↩︎
-
CORS の原理を知って正しく使おう https://youtu.be/ryztmcFf01Y ↩︎
Discussion