🧞

SWRを使おうぜという話2022

2022/09/05に公開

はじめに

2021年1月に以下のような記事を書きました。
https://zenn.dev/mast1ff/articles/40b3ea4e221c36

内容はVercel社のオープンソースプロジェクトの一つであるデータフェッチライブラリであるSWRの紹介で、記事内に間違いなどもあったにも関わらずたくさんの反響を頂きました。
2022年半ばとなった今でも「いいね」を頂いております。

しかし、内容は2021年当時のものであり、ライブラリの仕様が少し変更となっておりますので、現在のSWRの仕様に合わせて新しく記事を書くことに致しました。

当記事の内容は「SWRを使おうぜという話」のシナリオに沿っての再掲と致します。
最後までどうぞお付き合いください。

SWRとはなにか

https://swr.vercel.app/ja
SWRは、クライアントJavaScriptからのデータ取得とそれに関連する操作を提供するReact Hooks群です。
通常、Reactを使用してAPIサーバーからのデータ取得を非同期で行う場合、useEffectfetchaxiosなどのHTTP通信用のクライアントを使用して行われます。

SWRを使わない実装

例として、Next.jsを使用してユーザーのログイン情報を取得するケースを想定します。
Next.jsの場合はSSRをサポートしているため、以下のような書き方ができます。

// サーバーサイドでのデータ取得処理
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";

interface PageProps {
  user: { id: string } | null;
}

export async function getServerSideprops({ req }: GerServerSidePropsContext): Promise<GetServerSidePropsResult<PageProps>> {
  const user = await getUser(req);
  return {
    props: {
      user
    }
  };
}

export default function Home({ user }: PageProps) {
  return (
    <div>
      {user ? <p>{user.id}</p> : <a href="/login">Log in</a>}
    </div>
  );
}

この実装だとサーバーサイドでのレスポンスを待たなくてはならない上にユーザー情報などのパーソナルなデータはキャッシュできないため、クライアント側で取得する操作を採用する場合が多いかと思います。

// クライアントサイドでの取得処理
- import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
+ import { useState, useEffect } from "react";

interface User {
  id: string;
}

-export async function getServerSideprops({ req }: GerServerSidePropsContext): Promise<GetServerSidePropsResult<PageProps>> {
-  const user = await getUser(req);
-  return {
-    props: {
-      user
-    }
-  };
-}

export default function Home() {
+  const [user, setUser] = useState<User | null>(null);

+  useEffect(() => {
+    fetch("/api/user", {
+      method: "get",
+      headers: {
+        "Content-Type": "application/json",
+      }
+    })
+      .then((res) => {
+        return res.json() as Promise<User | null>;
+      })
+      .then((data) => {
+        setUser(data);
+      });
+  }, []);
   
  return (
    <div>
      {user ? <p>{user.id}</p> : <a href="/login">Log in</a>}
    </div>
  );
}

さらにここに、

  • fetchしたuserがnullの場合
  • fetchの実行に失敗した場合
  • 取得中の処理

が加わると、その状態管理は少し複雑になってきます。

// クライアントサイドでの取得とエラー処理
import { useState, useEffect } from "react";

interface User {
  id: string;
}

export default function Home() {
- const [user, setUser] = useState<User | null>(null);
+ const [user, setUser] = useState<User | null | undefined>(undefined);
+ const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("/api/user", {
      method: "get",
      headers: {
        "Content-Type": "application/json",
      }
    })
      .then((res) => {
+       if (res.ok) {
+         return res.json() as Promise<User | null>;
+       }
+       throw new Error(`Some Error`);       
      })
      .then((data) => {
        setUser(data);
      })
+     .catch((err) => {
+       setError(err.message);
+	setUser(null);
      });
  }, []);
   
  return (
    <div>
-     {user ? <p>{user.id}</p> : <a href="/login">Log in</a>}
+     {typeof user === "undefined" ? (
+       <p>loading...</p>
+     ) : user ? (
+       <p>{user.id}</p>
+     ) : (
+       <a href="/login">Login</a>
+     )}
+     {error ? <p>{error}</p> : null}
    </div>
  );
}

だいぶ複雑になってきました。
しかしこれではページを再度ロードしたときに同じようにデータの取得完了を待たなくてはなりません。
そこでブラウザでのメモリキャッシュを導入したいと思います。

// クライアントサイドでの取得とエラー、キャッシュ処理
import { useState, useEffect } from "react";

interface User {
  id: string;
}

+ const cache = new Map<string | User>();

export default function Home() {
- const [user, setUser] = useState<User | null | undefined>(undefined);
+ const [user, setUser] = useState<User | null | undefined>(cache.has("/api/user") ? cache.get("/api/user") : undefined);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("/api/user", {
      method: "get",
      headers: {
        "Content-Type": "application/json",
      }
    })
      .then((res) => {
        if (res.ok) {
          return res.json() as Promise<User | null>;
        }
        throw new Error(`Some Error`);       
      })
      .then((data) => {
+       cache.set("/api/user", data);
        setUser(data);
      })
      .catch((err) => {
+       cache.set("/api/user", null);
        setError(err.message);
	setUser(null);
      });
  }, []);
   
  return (
    <div>
      {typeof user === "undefined" ? (
        <p>loading...</p>
      ) : user ? (
        <p>{user.id}</p>
      ) : (
        <a href="/login">Login</a>
      )}
      {error ? <p>{error}</p> : null}
    </div>
  );
}

ユーザーのログイン情報を判定するだけなのにこれだけの処理を書かなくてはならないのは大変です。眠い時にはうっかりミスをしてしまいそうですね。

SWRに助けてもらおう

SWRは、上記の

  • fetchを使用したクライアントサイドのデータ取得
  • データ取得状態の管理
  • エラー処理
  • データキャッシュ

を少ない記述で実現することが出来ます。
まずは上記の例を書き換えてみましょう。

// SWRでの実装
- import { useState, useEffect } from "react";
+ import useSWR from "swr";

interface User {
  id: string;
}

- const cache = new Map<string | User>();
async function fetcher(key: string, init?: RequestInit) {
  return fetch(key, init).then((res) => res.json() as Promise<User | null>);
}

export default function Home() {
- const [user, setUser] = useState<User | null | undefined>(undefined);
- const [user, setUser] = useState<User | null | undefined>(cache.has("/api/user") ? cache.get("/api/user") : undefined);
- const [error, setError] = useState<string | null>(null);
+ const { data: user, error } = useSWR("/api/user", fetcher);

- useEffect(() => {
-   fetch("/api/user", {
-     method: "get",
-     headers: {
-       "Content-Type": "application/json",
-     }
-   })
-     .then((res) => {
-       if (res.ok) {
-         return res.json() as Promise<User | null>;
-       }
-       throw new Error(`Some Error`);       
-     })
-     .then((data) => {
-       cache.set("/api/user", data);
-       setUser(data);
-     })
-     .catch((err) => {
-       cache.set("/api/user", null);
-       setError(err.message);
-       setUser(null);
-     });
- }, []);
   
  return (
    <div>
      {typeof user === "undefined" ? (
        <p>loading...</p>
      ) : user ? (
        <p>{user.id}</p>
      ) : (
        <a href="/login">Login</a>
      )}
      {error ? <p>{error}</p> : null}
    </div>
  );
}

ものすごく簡単になりましたね!!

SWRについて

ここからはSWRというライブラリについて紹介していきます。

SWRでは何を行っているのか

基本となるuseSWR関数は

  • key: リクエストするユニークな文字列(通常URLを指定する)
  • fetcher: (任意)第一引数に渡したURLを引数に取るfetch関数
  • options: (任意)SWRのオプション

の3つの引数を取ります。
この時第一引数に渡したkeyは、ブラウザでのデータキャッシュキーも兼ねています。

そしてこの関数は

  • data: fetcherによって取得したデータ
  • error: fetcherによってthrowされたエラー
  • isValidating: リクエストまたは再検証の読み込みがあるか否かのBool値
  • mutate: キャッシュされたデータを更新する関数
    を返却します。
    このとき、dataの状態は
  • 取得が完了していない場合はundefined
  • 取得が完了した場合はその内容

となり、dataundefinedか否かで読み込み中かどうかをUIに伝えることが出来ます。

SWRでのオプション群

const { data, error, isValidating, mutate } = useSWR("/api/user", fetcher, {
  // options
});

SWRにはたくさんのオプションがあり、このオプションによりデータ操作をより細かく扱うことが出来ます。
代表的なものをいくつかご紹介します。
全てのオプションは公式ドキュメントをご参照ください。
https://swr.vercel.app/ja/docs/options

revalidateIfStale: 古いデータの再検証

デフォルト: true
データが取得済みであるときに、ページ遷移や他のコンポーネントで同フック同キーのuseSWR使用した場合にもデータの再検証を行い、新しいデータに更新します。

revalidateOnFocus: ウィンドウのフォーカス時の再検証

デフォルト: true
ブラウザから作業を離れて、再度ブラウザにフォーカスをしたときにデータの再取得を試みます。

revalidateOnReconnect: ブラウザのネットワーク接続回復時の再検証

デフォルト: true
ブラウザのネットワーク接続が切れた後に、再度接続が回復したと同時にデータを再検証します。
チャットなどのリアルタイムアプリケーションなどのデータ取得時に効果を発揮しそうですね。

refreshInterval: データの再検証時間

デフォルト: 0(無効)
数値を設定すると、<設定値>ミリ秒単位でポーリング取得(断続的にデータ取得を実行)を試みます。
こちらもリアルタイムアプリケーションなどで効果を発揮します。

shouldRetryOnError: エラー発生時の再実行

デフォルト: true
データの取得に失敗した場合に、再検証を試みます。

errorRetryInterval, errorRetryCount: エラー再実行設定

デフォルト: 5000, undefined
上記のshouldRetryOnErrorが有効の場合に、その

  • その再取得実行の間隔
  • 再取得の実行回数

をそれぞれ指定できます。

fallbackData: 初期データ

データ取得が完了する前の初期データを注入できます。

グローバルオプション

全てのSWRフックで適用したいオプションがあるならば、Contextで設定を渡すことが出来ます。
エラーハンドリングやfetcher関数などは毎回作成が面倒な場合にグローバルに設定することがままあります。

import useSWR, { SWRConfig } from "swr";

async function fetcher(key: string, init?: RequestInit) {
  return fetch(key, init).then((res) => res.json());
}

function User() {
  const { data } = useSWR("/api/user");
  /// ...
}

function App() {
  return (
    <SWRConfig
      value={{
        revalidateIfStale: false,
        fetcher
      }}
    >
      <User />
    </SWRConfig>
  );
}

データの更新処理

通常はデータの取得に使用するSWRですが、mutate関数を使用して更新処理の実装も可能です。

export default function Home() {
  const { data, mutate } = useSWR("/api/user");
  const [name, setName] = useState("");
  
  const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    await mutate(async (user) => {
      const res = await fetch("/api/user", {
        method: "post",
	body: JSON.stringify({ id: user.id, name }),
	headers: { "Content-Type": "application/json" }
      });
      const data = await res.json();
      return data;
    });
  }, [mutate, name]);
  
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={name} onChange={(e) => setName(e.currentTarget.value)} />
      <button type="submit">Submit</button>
    </form>
  );
}

mutate関数では、

  • 取得済みのデータを引数に取り
  • 新しいデータを返却する

非同期関数を引数に渡すことで、データの更新を行います。
この時関数内で行うHTTPリクエストで、POSTやPATCHなどの他のメソッドを使用したデータ更新処理を行うことが出来ます。

そのほかの仲間たち

通常のuseSWRの他に、

  • 大量のデータをページングするためのuseSWRInfinite
  • データを一度だけ取得するuseSWRImmutable
  • SWRの設定を取得するuseSWRConfig

などのHooksが用意されており、クライアントサイドのデータ取得に関わる殆どのUI処理パターンを網羅できるかと思われます。

さいごに

Reactでのデータ取得→状態管理の流れは、実装を間違えると複雑かつ依存だらけとなってしまいます。
また、アプリが巨大化してくるほど、そのロジックの一部を変更するだけでも一苦労です。
データを取得したり更新したりするバックエンドAPIのエンドポイントと返却するデータ型さえ決まっていれば、どのプロジェクトのどのコンポーネントでも使用できるはうれしいですね。

現状のアプリケーションの一部だけでも使用できるので、是非試しに使ってみてください。

最後までお読みくださり有難うございました。

Discussion