Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

リモートのデータ取得のためのフックライブラリの SWR を使ってみる

こんにちは。Mercari Advent Calendar 2019 の 17 日目は Web UX Team 所属の @lightnet328 がお送りします。

どのようにリモートのデータを取得して管理するかは SPA 構成の Web フロントエンドにおいて大きなテーマの 1 つだと思います。最近では Apollo Client のようにデータ取得のためのクライアントとデータ管理のためのキャッシュ機構が一体化したライブラリが出てきています。

本稿で紹介する zeit/swr (以下、SWR) もそのような特徴を持っています。SWR はその名が示すとおり HTTP の stale-while-revalidate (略すと swr) というキャッシュ戦略に影響を受けているため、先に HTTP の state-while-ravalidate について紹介します。

HTTP の Cache-Control と stale-while-revalidate

HTTP ではクライアントがサーバーから以前に取得したデータを再度取得せずに済むように、Cache-Control ヘッダーを用いてどのようにキャッシュしておくか指定できます。

例えば、Web 版のメルカリのロゴの Cache-Control には

cache-control: public,max-age=86400

が指定されていて、これは「このデータを最大で 86400 秒 (= 24 時間)キャッシュしてよい」ということを示しています。また、publicmax-age=86400 といったカンマで区切られた値のことを Cache-Control ディレクティブと呼びます。

stale-while-revalidate も Cache-Control ディレクティブの 1 つで、このディレクティブを使用することでクライアントはデータの新鮮さとレスポンスの速さのバランスを取りながらキャッシュを使用できます。

具体的には、クライアントがサーバーにリクエストを投げるときに既に使用できるキャッシュがある場合、キャッシュを使用し、その後にサーバーにリクエストを投げてキャッシュを更新します。更新されたキャッシュは次回に同じリクエストが投げられたときに使用されます。

f:id:lightnet328:20191216193120p:plain

また、キャッシュがない場合はサーバーにリクエストを投げ、レスポンスをキャッシュに保存します。

f:id:lightnet328:20191216193143p:plain

実際にどのように動くかは、 Keeping things fresh with stale-while-revalidateLive Example が参考になります。

HTTP では Cache-Control ヘッダーの他にも ETag ヘッダーや Expires ヘッダーによってキャッシュの可能性や有効期限を表現できます。

SWR とは

SWR は主に useSWR というカスタムフックを提供します。HTTP の stale-while-revalidate と違う点として、キャッシュがある場合に投げたリクエストのレスポンスを即時に使用します。 また、SWR はデータを取得する方法には関心がなく、HTTP クライアントや GraphQL クライアント、gRPC クライアントのような任意のクライアントと組み合わせられます。

使用例

useSWR を使用するとデータの取得と定期的な再取得を以下のようなコードで行えます。useSWRdataerror の他にも isValidating いう真偽値と revalidate という関数を返します。isValidating はキャッシュがないときや再取得においてリクエストが発生していると true を返します。revalidate は手動で再取得行うための関数です。以下のコードでは data === undefined の条件式で loading の表示を切り替えていますが、これはdata === undefined のときのみキャッシュが存在しないことを示しています。また、ここで isValidating を使用するとキャッシュの意味がなくなってしまいます。

import useSWR, { SWRConfig } from "swr";

const User = () => {
  const { data, error } = useSWR("/api/user");

  if (error) return "failed to load";
  if (data === undefined) return "loading...";
  return `hello ${data.name}!`;
};

const fetcher = (...args) => fetch(...args).then(res => res.json());

const App = () => (
  <SWRConfig value={{ fetcher }}>
    <User />
  </SWRConfig>
);

SWR は fetcher と呼ばれる、データを取得するための関数を外部から受け取ることで任意のクライアントと組み合わせることができるようになっています。useSWR の第一引数にはリクエストのためのキーを渡します。例えば、fetcher が HTTP クライアントの場合は URL やクエリパラメータ、GraphQL クライアントの場合は GraphQL クエリなどです。渡されたキーはキャッシュのキーとしても使用されます。第二引数には fetcher を渡せます。第二引数が省略されると、SWRConfig から提供されたデフォルトの fetcher が使用されます。

何が嬉しいのか

SWR を使用することでデータの取得に関する状態の管理を SWR に任せることができ、自分たちで記述するコード量を削減できます。また、取得したデータはキャッシュに保持されるため、ページ遷移などでコンポーネントがアンマウントしてもデータが消失せず、再度同じコンポーネントを表示する際に高速にデータを表示できます。

現時点での制限

現時点では SWR はキャッシュのストアをユーザーが直接参照することを想定していません。SWR はデータをキャッシュのみから取得できず、必ず一度はサーバーからデータを取得します。これは親と子のコンポーネントが同じデータを SWR から取得しようとした際に問題になります。具体的には子コンポーネントは親コンポーネントが既にキャッシュを更新したにも関わらず、余分なリクエストを送信してしまいます。レンダリングのパフォーマンスには影響はありませんが、余分なリクエストはサーバーに負荷を不用意にかけてしまうため、避けたいです。

以下のコードでは親と子が同じデータを SWR から取得します。サンプルコードなのでコンポーネントツリーが浅くなっており props でデータを渡せば良いように思いますが実際にはツリーが深いケースを想定していただければと思います。

const TodoList = () => {
  const { data } = useSWR("/api/todos");

  return data === undefined ? null : (
    <ul>
      {data.map(({ id, title }) => (
        <li key={id}>{title}</li>
      ))}
    </ul>
  );
};

const TodoPage = () => {
  const { data } = useSWR("/api/todos");
  const itemCount = data === undefined ? 0 : data.length;

  return (
    <>
      <h1>{`Todo (${itemCount})`}</h1>
      {data === undefined ? <div>loading...</div> : <TodoList />}
    </>
  );
};

const fetcher = (...args) => fetch(...args).then(res => res.json());

const App = () => (
  <SWRConfig value={{ fetcher }}>
    <TodoPage />
  </SWRConfig>
);

このコードを実行した際に発生するリクエストの開始と終了の時間を上に、ページの描画結果を下に示したものが以下の図になります。

f:id:lightnet328:20191217032501p:plain

stale-while-revalidate としては正しい挙動ですが、TodoList コンポーネントは TopPage コンポーネントがキャッシュしたデータを使用してアイテムを表示しているにも関わらず、データを再取得してしまいます。

余分なリクエストを送らないようにするためには子が SWR からデータを取得せずに、親から props を受け取る必要があります。これはコンポーネントツリーが深くなると prop drilling の問題を引き起こします。これを回避するためには、別途 Context API を使用して、データを二重で管理する必要があります。

ちなみに、Apollo Client は stale-while-revalidate 戦略を使用していないため、クエリを実行する際にキャッシュがあればそれを返し、無ければリモートから取得するだけで済みます。また、キャッシュからデータを直接読み取ることもできます

なお、SWR の Issue の作者のコメントによれば、キャッシュ API を公開する予定があるようなので、この制限は将来的に解消される可能性があります。

どのようなときに使えるか

SWR の特徴から以下のときに使用すると効果を発揮すると思います。

  • アプリケーションの規模がそれほど大きくならないとわかっている

    • コンポーネントツリーが深くなるほどコンポーネント間のデータの受け渡しは複雑になるため、ストアを参照してデータの受け渡しを減らしたくなりますが、現時点では SWR のキャッシュをユーザーは参照できません
  • 表示のパフォーマンスを保ちながらリモートから取得するデータをできるだけ新鮮に保ちたい

    • SWR はリモートからのデータ取得と管理をキャッシュ機構と取得に関する多くのオプションでサポートできます

また、SWR は Apollo Client と異なり、ローカル*1 の状態を管理する方法にはあまり関心がないため、ローカルの状態管理には Redux や Context API が必要になるかもしれません。Apollo Client はローカルの状態の管理の手法を提供しており、 Redux のようなグローバルのストアを置き換えようとしていることがわかります。

まとめ

SWR はまだリリースされてから日が浅いため(2019 年 10 月 30 日にリリース)、今後の機能追加が求められる点がいくつかあります。しかし、SWR を使用することで非常にシンプルなコードでリモートのデータの取得と管理を行えます。個人的に、今後はリモートのデータを取得するクライアントがキャッシュ機構を持ち、コンポーネントはストアを参照するパターンが一定の支持を得ていくのではないかと思っています。

明日 18 日目の執筆担当は @kokoheia です。引き続きお楽しみください 😄

*1: 紛らわしいですが、ここでのローカルはリモートと対比するローカルを指しています。グローバルとローカルで対比させた場合、一般的に Redux も Context API もグローバルの状態を管理します。