🥞

useSWRはどのようにキャッシュを管理しているのか

2024/04/02に公開

こんにちは。e-dash 株式会社でフロントエンドを担当している yuuki1036 です。弊社では BtoB の SaaS プロダクトを開発しています。

最近 Next.js プロジェクトで React Hooks データフェッチライブラリの SWR を使用しました。useSWR はフェッチライブラリを選ばない点において導入しやすく、データを含む 4 つの状態を返却するだけ、という使用側にとって非常に扱いやすい作りになっています。動作については公式にてある程度説明されていますが、実際に内部で何が行われているのか調べてみました。

https://github.com/vercel/swr

useSWR の基本

以下は useSWR の基本的な使い方です。第 1 引数をキーとしてフェッチデータをキャッシュし返却します。返り値の state (data, error, isLoading, isValidating) は状況によって更新されコンポーネントの再レンダリングを引き起こします。

// データfetchする非同期関数
const fetcher = fetch('/api/hoge').then((res) => res.json());

// データを取得するフック
const { data, error, isLoading, isValidating } = useSWR('/api/hoge', fetcher);

return <div>fetch data is {data}</div>;

useSWRHandler の概要

useSWR() のコア部分はuseSWRHandler()として 約 650 行のコードで構成されており、スナップショットの生成・リクエストの実行など一連の処理が記述されていました。

use-swr.ts
export const useSWRHandler = <Data = any, Error = any>(
  _key: Key,
  fetcher: Fetcher<Data> | null,
  config: FullConfiguration & SWRConfiguration<Data, Error>
) => {
...

以下はコンポーネントがマウントされてから API データを取得するまでのおおまかな流れです。

  1. 第 1 引数の _key をキーとしたキャッシュの初期値を作成
  2. 1 のキャッシュをuseSyncExternalStore(以下 useSES)から監視させる
  3. キャッシュをクライアント側に返却する
  4. コンポーネントのレンダリングが完了
  5. API へのリクエストを実行する
  6. レスポンス取得後、キャッシュを更新する
  7. useSES がキャッシュの変更を検知してコンポーネントの再レンダリングを引き起こす
  8. コンポーネントに値が反映される

キャッシュの仕組み

SWR はキャッシュの管理をcacheglobalStateで行っています。cache は第 1 引数 _key と state を関連付けた Map です。state は useSWR() の返り値で、前述の 4 つの状態です。cache は useContext によりグローバルに保持されます。

types.ts
export type State<Data = any, Error = any> = {
  data?: Data
  error?: Error
  isValidating?: boolean
  isLoading?: boolean
}
...
export interface Cache<Data = any> {
  keys(): IterableIterator<string>
  get(key: string): State<Data> | undefined
  set(key: string, value: State<Data>): void
  delete(key: string): void
}

globalState はその他情報を管理しており同様にグローバルに保持されます。Record 型のキーは第 1 引数の _key です。MUTATION や FETCH にはタイムスタンプが記録され、リクエストの前後関係を把握できるようになっています。

types.ts
export type GlobalState = [
  Record<string, RevalidateCallback[]>, // EVENT_REVALIDATORS
  Record<string, [number, number]>, // MUTATION: [ts, end_ts]
  Record<string, [any, number]>, // FETCH: [data, ts]
  Record<string, FetcherResponse<any>>, // PRELOAD
  ScopedMutator, // Mutator
  (key: string, value: any, prev: any) => void, // Setter
  (key: string, callback: (current: any, prev: any) => void) => () => void // Subscriber
]

useSES はクライアント側に再レンダリングを促すトリガーを担っています。cache (各 _key ごとの state) はスナップショットとして useSES にサブスクライブされます。 useSES は React 外で状態を共有することができるので、状態管理ライブラリによく用いられるそうです。

第 1 引数 _key

useSWRHandler の主要処理を順に見ていきます。
冒頭では第 1 引数 _key のシリアライズが行われています。_key はキャッシュの管理識別子です。文字列の他に配列を指定できるのは知っていましたが、その以外にオブジェクトや関数または fetcher に渡される引数も設定できるようです。_key は falsy な値も受け付けており、その場合はリクエストの実行を行いません。文字列でない場合はハッシュ値に変換されます。

use-swr.ts
const [key, fnArg] = serialize(_key)
types.ts
export type Arguments =
  | string
  | ArgumentsTuple
  | Record<any, any>
  | null
  | undefined
  | false
export type Key = Arguments | (() => Arguments)

useSES による cache のサブスクライブ

getSnapshot()は useSES にサブスクライブさせるために必要な関数を返します。ここで useSES について少し説明します。第 2 引数getSnapshotはスナップショットを返す関数を設定します。この関数内で以前のスナップショットと別の値(オブジェクト)を返すことで useSES にストアの変更を検知させ、第 1 引数subscribe関数が実行されます。SWR は key ごとにコンポーネントを再レンダリングする callback を格納しており、それらが順次実行されることで関連するコンポーネントが再レンダリングされます。

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

getSnapshot() は cache と key を依存値としてメモ化されており、メモ化されたスナップショット(memorizedSnapshot)と最新のスナップショット(newSnapshot)を返り値の関数内で比較することで useSES にスナップショットの更新を伝えます。返り値はそれぞれ useSES のgetSnapshot getServerSnapshotに代入されます。

use-swr.ts
const getSnapshot = useMemo(() => {
    ...
    const cachedData = getCache()
    const clientSnapshot = getSelectedCache(cachedData)
    let memorizedSnapshot = clientSnapshot
    ...
    return [
      () => {
        const newSnapshot = getSelectedCache(getCache())
        const compareResult = isEqual(newSnapshot, memorizedSnapshot)

        if (compareResult) {
          memorizedSnapshot.data = newSnapshot.data
          memorizedSnapshot.isLoading = newSnapshot.isLoading
          memorizedSnapshot.isValidating = newSnapshot.isValidating
          memorizedSnapshot.error = newSnapshot.error
          return memorizedSnapshot
        } else {
          memorizedSnapshot = newSnapshot
          return newSnapshot
        }
      },
      () => serverSnapshot
    ]
}, [cache, key])

useSES は getSnapshot を定期的に実行することでスナップショットの変更を検知します。メモ化された getSnapshot() はクロージャになっているため、memorizedSnapshot は古いスナップショットとして保持されます。getSnapshot 内で取得された newSnapshot は実行時の最新のスナップショットであるため、isEqual() による比較が機能します。
スナップショットが更新された場合による再レンダリングや cache, key の更新によるメモ化された getSnapshot() の再実行により memorizedSnapshot は更新されます。

また、getSnapshot では比較結果が同一値の場合になぜか newSnapshot の各値を memorizedSnapshot に代入しています。こちらについては実際のコードにコメントが記載されており、こちらの PR によるスナップショットが更新されないバグの修正となっています。useSWR は不要な再レンダリングを避けるため、クライアント側で使用しているフィールド(data, isLoading 等)のみを isEqual() で比較しているので、使用していないフィールドの変更を useSES は検知することができなかったようです。非常に興味深いですね!

初期 state の返却

初回実行時は useSES からスナップショット取得をした後に処理を終了し、以下のデータを返します。リクエスト実行に関わる処理はuseIsomorphicLayoutEffect内に記述されているため、コンポーネントがレンダリングされた後に行われます。

{ data: undefined, error: undefined, isLoading: true, isValidating: true }

リクエストの開始

コンポーネントがレンダリングされると、第 2 引数 fetcher をラップしたrevalidate()が実行されます。リクエスト開始時に globalState の FETCH オブジェクトに取得データとタイムスタンプが保存されます。タイムスタンプはリクエスト実行ごとに 1 ずつ増えるカウンターです。
fetcher を一旦格納し FETCH から取得して実行することで、重複を排除しているのがとてもスマートです。

use-swr.ts
const revalidate = useCallback(
  async (revalidateOpts?: RevalidatorOptions): Promise<boolean> => {
    const currentFetcher = fetcherRef.current
    ...
    try {
        if (shouldStartNewRequest) {
          ...
          FETCH[key] = [
            currentFetcher(fnArg as DefinitelyTruthy<Key>),
            getTimestamp()
          ]
        }
        // Wait until the ongoing request is done. Deduplication is also
        // considered here.
        ;[newData, startAt] = FETCH[key]
        newData = await newData
        ...

revalidate() 内ではそのほかに複数リクエストが同時進行している場合の処理を行います。globalState FETCH はリクエスト開始時、 MUTATION はリクエスト開始時・取得完了時にタイムスタンプを記録しているので、SWR は各リクエストの時系列的な状況を知ることができます。

データ取得完了・スナップショットの更新

データ取得後(または取得データの破棄後)にfinishRequestAndUpdateState()が呼ばれ cache が更新されます。setCache()による cashe の更新は useSES にサブスクライブされ snapshot の更新が行われます。それに伴い、同一 key を呼び出しているコンポーネントが再レンダリングされます。

use-swr.ts
const finishRequestAndUpdateState = () => {
  setCache(finalState)
}
{ data: 取得データ, error: undefined, isLoading: false, isValidating: false }

再検証の仕組み

useSWR はクライアント側で発生したイベントを検知した場合や、データの取得に失敗したときにデータの再検証を行ってくれます。その際使用されるのが globalState EVENT_REVALIDATORS です。こちらには前述の revalidate() をラップしたonRevalidate()が key ごとに格納されます。onRevalidate() はイベントごとの再検証処理が記述されており、globalState に保存することで外部から利用できるようになっています。

EVENT_REVALIDATORS に格納される onRevalidate()
use-swr.ts
const onRevalidate = (
      type: RevalidateEvent,
      opts: {
        retryCount?: number
        dedupe?: boolean
      } = {}
    ) => {
      if (type == revalidateEvents.FOCUS_EVENT) {
        const now = Date.now()
        if (
          getConfig().revalidateOnFocus &&
          now > nextFocusRevalidatedAt &&
          isActive()
        ) {
          nextFocusRevalidatedAt = now + getConfig().focusThrottleInterval
          softRevalidate()
        }
      } else if (type == revalidateEvents.RECONNECT_EVENT) {
        if (getConfig().revalidateOnReconnect && isActive()) {
          softRevalidate()
        }
      } else if (type == revalidateEvents.MUTATE_EVENT) {
        return revalidate()
      } else if (type == revalidateEvents.ERROR_REVALIDATE_EVENT) {
        return revalidate(opts)
      }
      return
    }

感想

普段使用しているライブラリのいい感じにやってくれている処理を知ることができて良かったです。特にパフォーマンス(再レンダリングの低減)とリアルタイム性を両立している部分はとても参考になりました。useSWR は保守の段階に入っているライブラリですが、クライアント側のデータフェッチライブラリとしてとても優秀なのでぜひ使ってみてください!

採用情報

e-dash エンジニアチームは現在一緒にはたらく仲間を募集中です!
同じ夢について語り合える仲間と一緒に、環境問題を解決するプロダクトを作りませんか?

Discussion