SWRを導入してユーザー体験を向上させた話 (Next.js)

こんにちは、プロダクト開発エンジニアの神長(kaminaga)です。

High Linkでは「カラリア 香りの定期便」などのサービスを開発しています。

coloria.jp

カラリア 香りの定期便」はNext.jsを利用して構築されており、今回はユーザー体験の向上を目的に、データ取得/キャッシュライブラリの SWRを導入した話です。

目次

SWR導入の背景

【発端】ブラウザバック時スクロール位置を復元したい

Next.jsのデフォルトの挙動ではブラウザバック時に、元の位置でなくページトップに戻ってしまい、ページを行ったり来たりする際の体験がよくありません。

そこで、Next.jsの設定オプションの scrollRestoration(2022/11現在、experimental)を設定することで、ブラウザバック時に元いた位置に戻れるようにしようと考えました。( 参考 )

scrollRestoration を設定したが、スクロール復元位置がずれる

scrollRestorationの設定で一件落着と思いきや、 ブラウザバック時、スクロール位置がずれてしまうことがあることがわかりました。

Ajaxによる非同期データ取得を行なっているので、ページ読み込み後にレイアウトシフトが発生してしまう為です。

Ajaxによるレイアウトシフトをどう回避するか

非同期データ取得(Ajax)によるレイアウトシフトへの対応策として、2つの対応案を考えました。

対応策① スクロール位置を保存しておき、ブラウザバック時に一定の遅延後、スクロール位置を復元する

  • Pros
    • 一括で全ページ対応が可能である。
  • Cons
    • スクロール位置の保存処理・遅延復元処理(scrollRestorationにあたる処理)を自前実装する必要がある。
    • APIコールの待機後、遅延してスクロール位置を復元するので若干チラつきが発生する。
    • ややハッキーで、実装が若干複雑になるきらいがある。

対応策② APIキャッシュを導入し、非同期APIコールによるブラウザバック時のレイアウトシフトが発生しないようにする

  • Pros
    • 問題のレイアウトシフト自体を解消でき、ユーザー体験が最善に近づく。
    • 場当たり的でない根本対応であり、ハックが不要なため、将来的な負債になりづらい。
  • Cons
    • Ajaxによるレイアウトシフトが発生する全ページへの対応が必要である。

プロトタイピングによるチームレビュー影響範囲等の技術調査を進めた結果、 ユーザー体験の最適化実装面のシンプルさの観点から、根本対応である「対応策② APIキャッシュを導入し、非同期APIコールによるレイアウトシフトが発生しないようにする」を採用することにしました。

Next.jsにAPIキャッシュ(SWR)を入れる

非同期データ取得(Ajax)によるレイアウトシフトやページのチラつきの解消のため、Next.jsにAPIキャッシュを導入することにしました。

SWRは、データ取得/キャッシュを実現するシンプルで軽量なライブラリで、ユースケースや文献も比較的多く存在しており、今回の課題の解決に最適と判断しました。

SWRとは

SWRという名前は、stale-while-revalidateの略で、HTTPプロトコルRFC HTTP RFC 5861で提唱されたキャッシュ手法に由来しています。

このRFCではstale-while-revalidate は以下のように説明されています。

The stale-while-revalidate HTTP Cache-Control extension allows a cache to immediately return a stale response while it revalidates it in the background, thereby hiding latency (both in the network and on the server) from clients.

バックグラウンドでAPIを再検証(呼び出し)している間に一旦古いレスポンスを返却する。これによってAPI呼び出しのレイテンシが隠蔽される。 (意訳)

このキャッシュ手法に由来したReact Hooks ライブラリが SWRです。

SWR動作イメージ

SWR は一旦キャッシュを同期的に返しつつ、バックグラウンドでAPIをコールすることで、 API呼び出しのレイテンシによるユーザー体験への影響を解消しつつ、データの最新性を担保します。

SWRの使い方

サンプルコードを通してSWRの基本的な使い方を紹介します。

実際に使われているコードがベースとなっている為、実践的な内容になっています。

// hooks.ts ============
const API_ENDPOINT_URL = '/api/user_data'

// APIをコールする関数
const fetcher = async (url: string): Promise<User> => {
  return await get(url) // 任意のAPIコール関数。modelへの詰替等
}

export const useUser = () => {
  // dataはfetcher(API_ENDPOINT_URL)の返り値。fetcherコール中はキャッシュ値またはundefinedが入る
  // 非同期でfetcherをコールし、最新の返り値でdata(とキャッシュ)が更新される
  const { data: user, error } = useSWR<User>(API_ENDPOINT_URL, fetcher)

  // ローディングを表現するスニペット
  const isLoading = !error && !user

  return { user, isLoading, error }
}

// page.tsx =========
export const UserPage: VFC = () => {
  // 初回呼び出し時、userの値は、undefinedまたはキャッシュ値
  // APIレスポンスの取得後、userの値は、最新の値でリアクティブに置き換わる
  const { user, isLoading } = useUser()

  return <div> {isLoading ? '読み込み中' : user.name} </div>
}

こちらがSWRの呼び出し部分です。

  // dataはfetcher(API_ENDPOINT_URL)の返り値。fetcherコール中はキャッシュ値またはundefinedが入る
  // 非同期でfetcherをコールし、最新の返り値でdata(とキャッシュ)が更新される
  const { data: user, error } = useSWR<User>(API_ENDPOINT_URL, fetcher)

useSWRの第一引数にはURLを、第二引数に非同期でコールされる関数オブジェクトを渡します。 第一引数の値はキャッシュキーとして利用されます。

SWRは第一引数の値(=key)を利用してキャッシュを検索し、存在すればdataに反映します。 その後、第一引数の値(多くの場合API URL)を引数にして fetcher を非同期でコールします。 fetcher の解決後、最新の返り値を data にリアクティブに反映し、キャッシュも更新します。

fetcherの呼び出し時、例外が送出されると、送出された例外オブジェクトが errorに反映されます(正常時はundefined)。

条件付きフェッチ

useSWR は第一引数に null を渡すと、fetcherをコールしません。 これを利用して、特定条件時のみAPIをコールすることができます。

// 第一引数(key)がnullであればfetcherはコールされない
const { data: user, error } = useSWR<User>(
  userId ? `${API_ENDPOINT_URL}/${userId}` : null, // userIdがある時だけAPIがコールされる
  fetcher
)

swr.vercel.app

配列キー

useSWRの第一引数に配列を渡せば、複数の値を複合キーとして利用することもできます。 fetcherには配列の値をアンパッキングした状態で引数として渡されます。

const fetcher = async (url: string, id: string): User => {
  return await get(`${url}?id=${id}`)
}

const { data: user, error } = useSWR<User>(
  [API_ENDPOINT_URL, userId], // URLとuserIdパラメータ値の複合キー
  fetcher // fetcher(API_ENDPOINT_URL, userId) という形でアンパックされて呼び出される
)

swr.vercel.app

データの最新化(ミューテーション)

通常、SWRはページ遷移時等のタイミングで自動でAPIを再度コールし、データを最新化してくれます。 このAPIの再コールのことをSWRでは「再検証(reValidation)」と呼んでいます。

データを表示しているだけの場合、自動で行われる再検証で十分ですが、 画面内で情報を更新するような場合、表示されているデータを即時更新する必要があります。

このような時に使うのが Mutation(ミューテーション)です。

const { data: user, error, mutate } = useSWR<User>(API_ENDPOINT_URL, fetcher)

const updateUserName = async (newName: string) => {
  await postUserName(newName) // 名前更新APIをPOST

  // すぐに表示内容が変わらないと困るのでmutateする
  mutate({ ...user, name: newName }) // 引数を渡さなければキャッシュをクリアして再検証
}

このようにmutate関数を呼び出すと、dataの値を(引数の値で)即時更新した後 再検証 が走り、サーバーサイドのデータとの同期が取られます。

また、mutateの第二引数に再検証を走らせない等のオプション値を渡すこともできます。

swr.vercel.app

SWRのオプション

SWRには色々なオプション値が用意されています。 利用頻度はそこまで高くはないですが、一部を紹介します。

// SWRの第三引数にオプションを渡せます
const { data: user, error } = useSWR<User>(API_ENDPOINT_URL, fetcher, {
  onSuccess: () => { /* 検証(APIコール)成功時に呼び出される処理を書ける */ },
  fallbackData: DEFAULT_USER_DATA // キャッシュがない場合の初期値をundefinedから変更できる
})

swr.vercel.app

SWRの共通設定(SWRConfig)

前述した SWRのオプション は、SWRConfigを利用することで、共通設定として定義することもできます。

// GlobalSWRConfig.tsx
// あえてコンポーネント化しましたが_app.tsxにベタ書きでも問題ない
export const GlobalSWRConfig: React.FC = ({ children }) => {
  // @see https://swr.vercel.app/ja/docs/options
  const SWROptions = {
    fetcher: fetch, // ここで共通のfetcherを指定すれば、useSWR()のfetcherを省略できる
    shouldRetryOnError: false, // 後述
    revalidateOnFocus: false, // 後述
  };

  return <SWRConfig value={SWROptions}>{children}</SWRConfig>;
}

// _app.tsx
export default App = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
    <GlobalSWRConfig>
      <Component {...pageProps} />
    </GlobalSWRConfig>
  );
}

こちらの例では以下のオプションをSWRConfigに設定しています

  • fetcher: useSWRで利用するfetcherを共通で指定できます。
  • shouldRetryOnError: fetcherの実行に失敗した際、SWRはデフォルトで再検証を行います。その挙動をOn/Offできます。
  • revalidateOnFocus: ウィンドウがフォーカスされた時、SWRはデフォルトで再検証を行います。その挙動をOn/Offできます。

その他の利用可能なオプションはこちらを参照してください。

swr.vercel.app

SWRを導入した結果

カラリアに SWRを導入した結果、ブラウザバック時のレイアウトシフトが解消され、Next.jsの scrollRestoration 機能を無事有効化することができました。

また、APIの非同期通信によるページのチラつき等が減り、ページ回遊時のユーザー体験も向上しました。


www.youtube.com

↑ブラウザバック時のレイアウトシフトがなくなり、元のスクロール位置にちゃんと戻ってくるようになった

まとめ

今回は、Next.jsに SWRを導入してみました。

SWRはシンプル軽量なライブラリであり、理解も容易で、APIキャッシュによるユーザー体験向上というユースケースをうまく実現できました。

SWRについての詳細は、公式ドキュメントがおすすめです。 読みやすくボリュームも大きすぎず、数十分で読み終えることができます。

swr.vercel.app

We are hiring!!

Highlinkのプロダクト開発ユニットでは、「カラリア」の開発等、ユーザーに価値を届けるエンジニアを募集中です。

ぜひカジュアルにお話をするだけでも歓迎です! 詳細は下記のリンクからどうぞ!

herp.careers