GLoves Blog
useSWRを使用したキャッシュ戦略

useSWRを使用したキャッシュ戦略

タグ
投稿日
2023-09-09

https://gloves-blog.vercel.app/articles/amplifycli-sample-todo-app

で作成したtodoアプリにuseSWRライブラリーを使って、修正をしたいと思います。


useSWRを導入するメリット

Stale Dataの使用・バックグラウンドでのデータ更新

useSWRはデフォルトでStale Dataを使用します。これはキャッシュに古いデータがある場合は最初にそのデータが表示されます。また、バックグランドでのデータ更新にもデフォルトで対応しています。Stale Dataが表示された後、バックグラウンドで新しいデータをフェッチし、データが更新されるとUIに反映がされます。


データの再検証

revalidationOnFocus(デフォルトでは有効)を使用することで、SWRのデフォルトの動作で、公式サイトにも記載がありますが、「古いモバイルタブやスリープ状態になったラップトップなどのシナリオでデータを更新する場合に役立ちます。」とあるように、たとえば、ページがフォーカスされるとデータは再検証されます。

また、定期的な再検証refreshIntervalオプションを活用することで、定期的なフェッチとUI更新が可能になります。多少し過ぎるとクライアント側及び、サーバー側の負荷が気になるところですが、WebSocket通信用のサーバーを別途立てるなどの必要がなくなるのは嬉しい!!!

他にもrevalidateOnReconnectオプション(再接続時の再検証)などもあります。

※AppSyncのsubscribe(Websocket通信)を使用できるのであれば勿論、Appsyncのsubscribeを使うべき。

https://swr.vercel.app/ja/docs/revalidation


エラーのリトライ

shouldRetryOnErrorオプションで指定でき、デフォルト値はtrue。最大試行回数の設定については、errorRetryCountオプションで指定が可能で、デフォルト値は2。

エラーリトライの実装例
const { data } = useSWR('/api/data', fetcher, { shouldRetryOnError: true });

重複排除・中間層のキャッシュ

同じキー値のuseSWRを複数呼び出した場合でも実際のリクエストは1回のみ。

また、SWRはグローバルキャッシュを持っているので、例えば別のページやコンポーネントで同じキーを使用してデータをフェッチする場合、キャッシュから直ちにデータを取得できます。

※ただし、通常のSPAの挙動と同じで手動でページをリロードすると、グローバルキャッシュはクリアされます。

function ComponentA() {
  const { data } = useSWR('/api/data');
  // ...
}

function ComponentB() {
  const { data } = useSWR('/api/data');
  // ...
}
// Page A
const { data } = useSWR('/api/data');

// Page B
const { data } = useSWR('/api/data');

インストール(npmコマンドのみ記載)

$ npm i swr

https://swr.vercel.app/ja/docs/getting-started


useSWRの導入

キャッシュプロバイダーの設定

ここではデフォルト値を設定します。プロジェクトに応じて基本設定をここで定義します。

https://swr.vercel.app/ja/docs/api.ja#オプション


use clientを指定する必要があるため、別コンポーネントに切り分けます。

src/components/SwrConfigProvider.tsx
"use client";
import { SWRConfig } from "swr";

const SWRConfigProvider = ({ children }: { children: React.ReactNode }) => {
  return (
    <SWRConfig
      value={{
        fetcher: undefined, // 指定しなくても良い。
        refreshInterval: 0,
        revalidateOnReconnect: true,
        shouldRetryOnError: true,
        errorRetryCount: 2,
      }}
    >
      {children}
    </SWRConfig>
  );
};

export default SWRConfigProvider;

SwrConfigProviderコンポーネントでラップします。

子コンポーネントでuseSWRのProviderをオーバーライドすることも可能です。

src/app/layout.tsx
import "@/app/globals.css";
import { theme } from "@/app/theme";
import type { Metadata } from "next";

import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import RecoilProvider from "@/store/Provider";
import { Container } from "@mui/material";
import "@aws-amplify/ui-react/styles.css";
import { Header } from "@/components/Header";
import { AuthListener } from "@/components/AuthListener";
import { SWRConfig } from "swr";
import SwrConfigProvider from "@/components/SwrConfigProvider";

export const metadata: Metadata = {
  title: "Todo App",
  description: "Created by Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <RecoilProvider>
        <AuthListener />
        <SwrConfigProvider>
          <ThemeProvider theme={theme}>
            <CssBaseline />
            <body>
              <Header />
              <Container>{children}</Container>
            </body>
          </ThemeProvider>
        </SwrConfigProvider>
      </RecoilProvider>
    </html>
  );
}

修正例:todo一覧画面の修正

src/app/pages
"use client";
import React, { useEffect } from "react";
import Link from "next/link";
import { Amplify, API, graphqlOperation } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button, Grid } from "@mui/material";
import { ListTodosQuery } from "@/API";
import useSWR from "swr";
import { todosState } from "@/store/todoState";
import { listTodos } from "@/graphql/queries";
import { Todo } from "@/components/todo";
import { useRecoilState } from "recoil";

Amplify.configure(awsconfig);

const fetcher = async () => {
  const result = (await API.graphql(
    graphqlOperation(listTodos)
  )) as GraphQLResult<ListTodosQuery>;

  return result?.data?.listTodos?.items || [];
};

const TodosIndex = () => {
  const [todos, setTodos] = useRecoilState(todosState);
  const { data: fetchedTodos, error } = useSWR("todosList", fetcher);

  useEffect(() => {
    if (fetchedTodos) {
      setTodos(fetchedTodos);
    }
  }, [fetchedTodos, setTodos]);

  if (error) {
    return <div>Error fetching todos</div>;
  }

  if (todos.length === 0) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
        <Grid item md={6}>
          <h1>Todos</h1>
        </Grid>
        <Grid item md={6}>
          <Link href="/todos/new">
            <Button variant="contained" color="primary">
              New
            </Button>
          </Link>
        </Grid>
      </Grid>
      <Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
        {todos.map((todo) =>
          todo ? <Todo key={todo.id} todo={todo} /> : null
        )}
      </Grid>
    </>
  );
};

ここではRecoilを使用してグローバル状態管理をする必要はないが、一応useEffectを使用してRecoil側の値を更新し使用をしています。


Suspense・Error boundaryによるローディング, エラー処理の追加


公式によると非推奨ではあるが、useSWRのオプションを使用したloading, エラー処理について記載します。

https://swr.vercel.app/ja/docs/suspense


キャッシュプロバイダーにonError, suspenseオプションの追加

src/components/SwrConfigProvider.tsx
"use client";
import { SWRConfig } from "swr";

const SWRConfigProvider = ({ children }: { children: React.ReactNode }) => {
  return (
    <SWRConfig
      value={{
        refreshInterval: 0,
        revalidateOnReconnect: true,
        shouldRetryOnError: true,
        errorRetryCount: 2,
        onError: (error) => {
          console.log("エラー情報の出力");
          console.error("An error occurred:", error);
          // その他のエラーハンドリング処理
        },
        suspense: true,
      }}
    >
      {children}
    </SWRConfig>
  );
};

export default SWRConfigProvider;

onErrorでエラーハンドリング処理を追加しておくと、error.tsxの処理と責務を分割できて良いかと思います。


src/app/page.tsx
const TodosIndex = () => {
  const [todos, setTodos] = useRecoilState(todosState);
  const { data: fetchedTodos } = useSWR("todosList", fetcher);

  useEffect(() => {
    if (fetchedTodos) {
      setTodos(fetchedTodos);
    }
  }, [fetchedTodos, setTodos]);

  // if (error) {
  //   return <div>Error fetching todos</div>;
  // }

  // if (todos.length === 0) {
  //   console.log("testtest");
  //   return <div>Loading...</div>;
  // }

error、loading処理を削除します。


loading.tsx、error.tsxファイルの追加

src/app/loading.tsx
export default function loading() {
  return <div>ローディング中</div>;
}
src/app/error.tsx
"use client";
export default function Error({ error }: { error: Error }) {
  return (
    <div className="m-4 font-bold">
      <p>エラー画面</p>
      <p>{error.message}</p>
    </div>
  );
}

【fetch処理でエラーが発生した場合の表示例】


キャッシュの書き換えについて

useSWRのmutate関数を使って、ローカルのキャッシュデータを直接変更することができます。その実装例が下記になります。

src/hooks/useAuthHook.ts
"use client";
import { useSetRecoilState } from "recoil";
import { authState } from "@/store/authState";
import { Auth, Hub } from "aws-amplify";
import useSWR, { mutate } from "swr";
import { useEffect } from "react";

const fetchAuthState = async () => {
  try {
    await Auth.currentAuthenticatedUser();
    return true;
  } catch (error) {
    return false;
  }
};

const useAuthHook = () => {
  const setAuth = useSetRecoilState(authState);
  const { data: isAuthenticated } = useSWR("authState", fetchAuthState, {
    revalidateOnFocus: false,
  });

  useEffect(() => {
    if (isAuthenticated !== undefined) {
      setAuth(isAuthenticated);
    }

    const unsubscribe = Hub.listen("auth", ({ payload: { event } }) => {
      switch (event) {
        case "signIn":
          mutate("authState", true);
          break;
        case "signOut":
          mutate("authState", false);
          break;
      }
    });

    return () => unsubscribe();
  }, [isAuthenticated, setAuth]);
};

export { useAuthHook };

signIn、signOutイベントでmutate関数を使ってSWRのキャッシュを変更し、その後Recoil側の値も変更しています。


useSWR自体にフェッチ処理以外で他の処理やライブラリとの依存関係が少ないと思われる。

Stale While Revalidateのキャッシュ戦略、エラーハンドリング、自動再検証を各コンポーネント毎に使い分けることで、ユーザー体験の向上に繋がると考えます。