DX

SWRで始めるフロントエンドのキャッシュ入門

青色が好きな山内です。AMBLでフロントエンドエンジニアをしています。
本記事では、Next.jsを用いて、APIレスポンスをキャッシュしていなかった状態のWebアプリケーションに、SWR(stale-while-revalidate)を用いてキャッシュを取り入れた際のナレッジを紹介したいと思います。

はじめに

本記事の対象者

  • Reactを使ったことがある
  • REST APIのレスポンスのキャッシュを行いたい
  • SWRに興味がある

本記事を作成する際に用いた開発環境

記載しているバージョンは記事作成当時(2022/08/17)のものです。

  • macOS 12.4
  • Next.js 12.2.4
  • SWR  1.3.0 | 2.0.0-beta.6

SWRとは

公式ガイドに記載の通り、広義のSWR(stale-while-revalidate)を実現するためのreact用のデータ取得ライブラリです。  
広義のSWRについてざっくり書くと、「とりあえずキャッシュデータ返しておくから、それを使っておいてね!バックグラウンドでAPIを実行してキャッシュを最新化しておくよ」といったものです。

今回はこのSWRを用いてAPIレスポンスをキャッシュしていきます。
尚、SWR以外にもReactQuery/RTK Query/Apollo Client等有名なものがいくつかあります。今回は以下の理由でSWRを採用しています。

  • Reduxを積極的に使っていなかったのでRTK Queryは除外
  • APIはREST APIなのでGraphQLベースのApolloClientは除外
  • ReactQuery/SWRの2択に絞った結果、SWRとNext.jsの開発チームが同じという点でSWRを選択

サンプルとなるWebアプリケーション

ここに、とあるユーザーを管理するだけのシンプルなユーザー管理アプリケーションがあります。ユーザー管理アプリケーションにはユーザーの一覧を表示する画面と、ユーザーを登録することができる画面だけがあります。  

ユーザー一覧画面

以下はユーザー一覧画面のイメージです。

実装は以下のイメージです。

UsersPage.tsx

import Link from 'next/link'
import { MainTemplate } from '@/components/templates'
import { useUsers } from '@/components/hooks'
import { UserList } from './UserList'

export const UsersPage = () => {
  const users = useUsers()

  return (
    <MainTemplate title="ユーザー一覧">
      <Link href="/users/create">
        <button>新規登録</button>
      </Link>
      {users.data ? <UserList users={users.data} /> : <div>loading...</div>}
    </MainTemplate>
  )
}

カスタムhooksであるuseUsersは初回マウント時にユーザー一覧APIを取得する責務をもつコンポーネントです。

useUsers.tsx

export const useUsers = () => {
  const [data, setData] = useState<User[] | null>(null)
  useEffect(() => {
    const fetch = async () => {
  // userRepository内はfetch or axios等のhttpClientを利用して
  // APIリクエストを行うオブジェクトです
      const users = await userRepository.gets()
      setData(users)
  // エラーハンドリングは割愛
    }
    fetch()
  }, [])

  return { data }
}


ユーザー登録画面

以下はユーザー登録画面のイメージです。

実装は以下のイメージです。

useCreatePage.tsx

export const UserCreatePage = () => {
  const router = useRouter()
  const { data: groups } = useGroups()

  // 登録ボタンを押下したとき
  const handleSubmit = async (formValues: UserCreateFormValues) => {
    // ユーザー登録処理
    await userRepository.post(formValues)
    // エラーハンドリングは割愛
    router.push('/users')
  }

  return (
    <MainTemplate title="ユーザー登録">
      <button onClick={() => router.push('/users')}>戻る</button>
      {groups ? (
        <UserForm groups={groups} onSubmit={handleSubmit} />
      ) : (
        <div>...Loading</div>
      )}
    </MainTemplate>
  )
}


現状の課題

現在のユーザ管理アプリケーションではAPIレスポンスのキャッシュは行っていません。

画面遷移を行う度に必要なデータがあれば都度APIリクエストを行う必要があります。

下記イメージの通り、画面遷移を行う度に…Loadingの文字が表示されてしまっています。

機能的には問題ありませんが、ユーザーからすると遷移の度に…Loadingが表示され、すこし煩わしい感じがします。

画面を開いたときに常に最新のデータであることを保証する必要がないのであれば、改善の余地があります。

特にデータが頻繁に更新されることがないアプリケーションであれば、画面遷移の度に常に最新のデータを取得しなおす必要なさそうです。  

そこで今回は以下の方針でキャッシュを利用していきたいと思います。

  • ユーザー一覧取得API/所属一覧取得APIのレスポンスはキャッシュする。
  • 所属一覧取得APIのリソースは基本的に更新されないので、所属一覧のキャッシュがあれば常にキャッシュを利用する。
  • ユーザー一覧のキャッシュは、ユーザーが新たにユーザーを登録したときのみ再度ユーザー一覧取得APIを実行しキャッシュを最新化する。それ以外の場合はキャッシュをそのまま利用する。

SWRを取り入れる

まずは、ユーザー一覧画面で実行するユーザー一覧取得APIのレスポンスをキャッシュしてみます。

useUsers.tsxのhooksをSWRを利用するコードに変更します。 

export const useUsers = () => {
  // エラーハンドリングは割愛
  const { data, mutate } = useSWR('users', () => userRepository.gets(), {
    // マウント時の再検証をやめる
    revalidateOnMount: false,
    // またそれ以外の自動再検証の設定をすべてやめる
    revalidateIfStale: false,
    revalidateOnFocus: false,
    revalidateOnReconnect: false,
  })

  // 初回マウント後にデータがない場合、再検証を行いデータを取得する
  useEffect(() => {
    if (!data) {
      mutate()
    }
  }, [data, mutate])

  return { data, mutate }
}

useSWRの具体的な利用方法は公式ガイドを参照ください。

ここではポイントとなる箇所のみ記載していきます。  
useSWRの第1引数はキャッシュしたいデータに対応するキーとなります。公式ガイドではURLを指定する例が多いですが、APIのエンドポイント等の情報はuserRepository内で定義しているので、ここでは単純にusersとしています。

また、optionsで自動で再検証を行う機能をすべてオフにしています。特にrevalidateOnMountをオンにすると、コンポーネントがマウントされたタイミングでキャッシュデータを最新化するためのAPIリクエストが行われてしまいます。

今回のキャッシュ利用方針では、ユーザーがデータの更新を行ったときのみ、キャッシュを最新化したいので自動でキャッシュの最新化を行う設定はすべてオフにしておきます。
一点、revalidateOnMountをオフにすると初回のAPIリクエストも行われないので、useEffectを利用して初回のAPIリクエストを行っています。  

続いて、ユーザー登録時の処理です。
ユーザー登録時にキャッシュの最新化を行う必要があるので、UserCreatePage.tsxに変更を加えます。  

export const UserCreatePage = () => {
  const router = useRouter()
  const { data: groups } = useGroups()
  const { mutate } = useUsers()
 
  // 登録ボタンを押下したとき
  const handleSubmit = async (formValues: UserCreateFormValues) => {
    // ユーザー登録処理
    await userRepository.post(formValues)
    // ユーザー一覧の再検証を行う
    await mutate()
    // エラーハンドリングは割愛
    router.push('/users')
  }

  return (
    <MainTemplate title="ユーザー登録">
      <button onClick={() => router.push('/users')}>戻る</button>
      {groups ? (
        <UserForm groups={groups} onSubmit={handleSubmit} />
      ) : (
        <div>...Loading</div>
      )}
    </MainTemplate>
  )
}



ユーザー登録処理の後にmutateを行っている点がポイントになります。  

mutateを行うとユーザー登録処理の後にユーザー一覧取得APIが実行され、キャッシュが最新化されます。  

stale-while-revalidateの思想にのっかるのであれば、mutateの処理を待たずにユーザー一覧画面に遷移し、キャッシュを洗い替えている最中のユーザー一覧画面の動作を定義するべきな気もしますが、ここでは単純にキャッシュが更新されてからユーザー一覧画面へと遷移しています。  

最後にuseGroupsも同様にswrへの置き換えが必要ですが、useUsersと同様なので割愛します。

この状態でユーザー管理アプリケーションをさわってみます。

ユーザー登録画面からユーザー一覧画面に戻ってきたときや、2回目以降ユーザー登録画面を表示したときに…Loading表示が消えました!

もっとキャッシュを活用する

キャッシュを利用することで、ユーザー一覧取得APIの実行回数を減らし、不要なLoadingをなくすことができました。  

ここではもう一歩進んでキャッシュに直接更新をかけることで、そもそもユーザー一覧取得APIの実行を省略するパターンを見ていきます。  

ユーザー登録APIのレスポンスを利用する

例えば、ユーザー登録APIのレスポンスがユーザー一覧取得APIの1ユーザーを返している場合を考えてみます。 

type User = {
  id: string
  name: string
  groupId: string
}
// ユーザー一覧取得APIのレスポンス
type GetUsersResponse = User[]
// ユーザー登録APIのレスポンス
type PostUserResponse = User

このような場合であれば、ユーザー登録後のレスポンスをユーザー一覧のキャッシュに直接追加することで、ユーザー一覧取得APIを再実行しなくとも、更新後の状態でユーザー一覧を表示することができそうです。  

以下は、ユーザー登録APIのレスポンスをキャッシュに追加している例になります。

const handleSubmit = async (formValues: UserCreateFormValues) => {
    mutate(
      async (users) => {
        const newUser = await userRepository.post(formValues)
        return [...(users || []), newUser]
      },
      {
        populateCache: true,
        // ユーザー一覧取得APIの再検証を行う
        revalidate: true,
        // フォームデータを直接キャッシュに書き込む
        optimisticData: (users) => [...(users || []), formValues],
        // ユーザー登録APIでエラーになったらキャッシュへの更新をロールバックする
        rollbackOnError: true,
      },
    ).catch((error) => {
      console.error('更新処理でエラー', error)
    })
    router.push(`/users`)
  }

どのタイミングのキャッシュから読み込んでいるかをわかりやすくするため、API側のレスポンスデータを加工しています。 

 

フォームの値→ユーザー登録APIのレスポンスの値→ユーザー一覧取得APIのレスポンスの値と変化していることがわかりますね。

このパターンを使うことはないとは思いますが、SWRでいろいろとできて面白いですね。

まとめ

SWRを利用することで、APIレスポンスをキャッシュし利用することができました。
従来のRedux等を使って個別に実装することに比べるととても簡単でした。

今回SWRを入り口にして、いかにユーザー体験のよいWebアプリケーションを実装するかという点に興味を持つことができたので、ぜひみなさんも利用してみてください。




あなたもAMBLで働いてみませんか?
AMBLは事業拡大に伴い、一緒に働く仲間を通年で募集しています。

データサイエンティスト、Webアプリケーションエンジニア、AWSエンジニア、ITコンサルタント、サービス運用エンジニアなどさまざまな職種とポジションで、自分の色を出してくださる方をお待ちしています。ご興味のある方は、採用サイトもご覧ください。

●AMBL採用ページ
-メンバーインタビュー (1日の仕事の流れ/やりがい/仕事内容)
-プロジェクトストーリー (プロジェクトでの実績/苦労エピソード)

●募集ページプリセールス/ エンジニア/クリエイター/ データサイエンティスト /営業・コンサルタント /コーポレート /サービス企画 /教育担当

ABOUT ME
ryoji yamauchi
front-end developerになりたいエンジニア。 静岡に住んでます。