Reactフックを使用した再検証中の古いデータフェッチ:ガイド

公開: 2022-03-11

古くなった再検証中のHTTPCache Cache-Control拡張機能を活用することは、一般的な手法です。 キャッシュされた(古い)アセットがキャッシュ内で見つかった場合はそれを使用し、キャッシュを再検証して、必要に応じて新しいバージョンのアセットで更新する必要があります。 したがって、名前はstale-while-revalidateです。

stale-while-revalidateどのように機能するか

リクエストが初めて送信されると、ブラウザによってキャッシュされます。 次に、同じリクエストが2回送信されると、最初にキャッシュがチェックされます。 そのリクエストのキャッシュが利用可能で有効な場合、キャッシュはレスポンスとして返されます。 次に、キャッシュの古さがチェックされ、古くなっていることが判明した場合は更新されます。 キャッシュの古さは、 Cache-Controlヘッダーにstale-while-revalidate revalidateとともに存在するmax-age値によって決定されます。

失効中の再検証ロジックを追跡するフローチャート。それはリクエストから始まります。キャッシュされていない場合、またはキャッシュが無効な場合は、要求が送信され、応答が返され、キャッシュが更新されます。それ以外の場合は、キャッシュされた応答が返され、その後、キャッシュの古さがチェックされます。古い場合は、リクエストが送信され、キャッシュが更新されます。

これにより、キャッシュされたアセットがクリティカルパスに存在しなくなるため、ページの読み込みを高速化できます。 それらは即座にロードされます。 また、開発者はキャッシュの使用頻度と更新頻度を制御するため、ブラウザーがユーザーに過度に古いデータを表示するのを防ぐことができます。

読者は、サーバーが応答で特定のヘッダーを使用し、ブラウザーにそこから取得させることができる場合、キャッシュにReactとHooksを使用する必要があると考えているかもしれません。

サーバーとブラウザーのアプローチは、静的コンテンツをキャッシュする場合にのみ適切に機能することがわかりました。 動的APIにstale-while-revalidateを使用するのはどうですか? その場合、 max-agestale-while-revalidate適切な値を思い付くのは困難です。 多くの場合、キャッシュを無効にし、リクエストが送信されるたびに新しい応答をフェッチすることが最良のオプションです。 これは事実上、キャッシュがまったくないことを意味します。 しかし、ReactとHooksを使えば、もっとうまくやることができます。

API stale-while-revalidate

HTTPのstale-while-revalidateは、API呼び出しなどの動的リクエストではうまく機能しないことに気づきました。

最終的にそれを使用したとしても、ブラウザはキャッシュまたは新しい応答のいずれかを返しますが、両方は返しません。 リクエストが送信されるたびに新しい応答が必要なため、これはAPIリクエストには適していません。 ただし、新しい応答を待つと、アプリの意味のあるユーザビリティが遅れます。

どうしようか?

カスタムキャッシュメカニズムを実装します。 その中で、キャッシュと新しい応答の両方を返す方法を見つけます。 UIでは、キャッシュされた応答は、使用可能になると新しい応答に置き換えられます。 ロジックは次のようになります。

  1. リクエストが初めてAPIサーバーエンドポイントに送信されるときは、レスポンスをキャッシュしてから返します。
  2. 次回同じAPIリクエストが発生したときは、キャッシュされたレスポンスをすぐに使用してください。
  3. 次に、リクエストを非同期で送信して、新しいレスポンスをフェッチします。 応答が到着すると、変更をUIに非同期的に伝播し、キャッシュを更新します。

このアプローチでは、すべてのAPIリクエストがキャッシュされるため、UIを瞬時に更新できますが、新しい応答データが利用可能になるとすぐに表示されるため、UIが最終的に正確になります。

このチュートリアルでは、これを実装する方法を段階的に説明します。 UIは新しい応答を取得したときに実際に更新されるため、このアプローチをstale-while-refreshと呼びます。

準備:API

このチュートリアルを開始するには、最初にデータをフェッチするAPIが必要です。 幸いなことに、利用可能なモックAPIサービスはたくさんあります。 このチュートリアルでは、reqres.inを使用します。

フェッチするデータは、 pageクエリパラメータを持つユーザーのリストです。 フェッチコードは次のようになります。

 fetch("https://reqres.in/api/users?page=2") .then(res => res.json()) .then(json => { console.log(json); });

このコードを実行すると、次の出力が得られます。 これは、非反復バージョンです。

 { page: 2, per_page: 6, total: 12, total_pages: 2, data: [ { id: 7, email: "[email protected]", first_name: "Michael", last_name: "Lawson", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/follettkyle/128.jpg" }, // 5 more items ] }

これは実際のAPIのようなものであることがわかります。 応答にはページネーションがあります。 pageクエリパラメータはページの変更を担当し、データセットには合計2つのページがあります。

ReactアプリでのAPIの使用

ReactアプリでAPIをどのように使用するかを見てみましょう。 それを行う方法がわかったら、キャッシング部分を理解します。 クラスを使用してコンポーネントを作成します。 コードは次のとおりです。

 import React from "react"; import PropTypes from "prop-types"; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { fetch(`https://reqres.in/api/users?page=${this.props.page}`) .then(res => res.json()) .then(json => { this.setState({ users: json.data }); }); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const users = this.state.users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{users}</div>; } } Component.propTypes = { page: PropTypes.number.isRequired };

実際のアプリケーションでよく発生するように、 propsを介してpage値を取得していることに注意してください。 また、 this.props.pageが変更されるたびにAPIデータを再フェッチするcomponentDidUpdate関数があります。

この時点で、APIはページごとに6つのアイテムを返すため、6人のユーザーのリストが表示されます。

Reactコンポーネントのプロトタイプのプレビュー:中央に6本の線があり、それぞれの名前の左側に写真があります。

更新中の古いキャッシュの追加

これにstale-while-refreshキャッシュを追加する場合は、アプリロジックを次のように更新する必要があります。

  1. リクエストが初めてフェッチされた後、リクエストの応答を一意にキャッシュします。
  2. リクエストのキャッシュが見つかった場合、キャッシュされた応答を即座に返します。 次に、リクエストを送信し、新しいレスポンスを非同期で返します。 また、次回のためにこの応答をキャッシュします。

これを行うには、キャッシュを一意に格納するグローバルCACHEオブジェクトを使用します。 一意性を保つために、 this.props.page値をCACHEオブジェクトのキーとして使用できます。 次に、上記のアルゴリズムをコーディングするだけです。

 import apiFetch from "./apiFetch"; const CACHE = {}; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ users: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/users?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ users: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { // same render code as above } }

キャッシュは検出されるとすぐに返され、新しい応答データもsetStateによって返されるため、これはシームレスなUI更新があり、2番目のリクエスト以降のアプリでの待機時間がなくなることを意味します。 これは完璧であり、一言で言えば、更新中の古い方法です。

更新中の古いロジックを追跡するフローチャート。それはリクエストから始まります。キャッシュされている場合、setState()はキャッシュされた応答で呼び出されます。いずれの場合も、要求が送信され、キャッシュが設定され、setState()が新しい応答で呼び出されます。

ここでのapiFetch関数は、 fetchのラッパーにすぎないため、キャッシュの利点をリアルタイムで確認できます。 これは、APIリクエストによって返されるusersのリストにランダムなユーザーを追加することによって行われます。 また、それにランダムな遅延を追加します。

 export default async function apiFetch(...args) { await delay(Math.ceil(400 + Math.random() * 300)); const res = await fetch(...args); const json = await res.json(); json.data.push(getFakeUser()); return json; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

ここでのgetFakeUser()関数は、偽のユーザーオブジェクトの作成を担当します。

これらの変更により、APIは以前よりもリアルになりました。

  1. 応答にランダムな遅延があります。
  2. 同じリクエストに対してわずかに異なるデータを返します。

このため、メインComponentからコンポーネントに渡されるpageプロップを変更すると、APIキャッシングの動作を確認できます。 このCodeSandboxで数秒に1回[切り替え]ボタンをクリックしてみると、次のような動作が見られるはずです。

キャッシュが有効になっているページの切り替えを示すアニメーション。詳細は記事に記載されています。

よく見ると、いくつかのことが起こります。

  1. アプリが起動してデフォルトの状態になると、7人のユーザーのリストが表示されます。 このリクエストが次に送信されるときにランダムに変更されるのはユーザーであるため、リストの最後のユーザーに注意してください。
  2. Toggleを初めてクリックすると、少しの間(400〜700ms)待機してから、リストを次のページに更新します。
  3. 今、私たちは2ページ目にいます。 リストの最後のユーザーに再度注意してください。
  4. ここで、もう一度Toggleをクリックすると、アプリは最初のページに戻ります。 最後のエントリは、手順1で書き留めたのと同じユーザーであり、後で新しい(ランダムな)ユーザーに変更されることに注意してください。 これは、最初はキャッシュが表示されていた後、実際の応答が開始されたためです。
  5. もう一度Toggleをクリックします。 同じ現象が起こります。 前回のキャッシュされた応答が即座に読み込まれ、新しいデータがフェッチされるため、手順3で書き留めたものからの最後のエントリの更新が表示されます。

これが、私たちが探していた更新中の古いキャッシュです。 しかし、このアプローチにはコードの重複の問題があります。 キャッシュを備えた別のデータフェッチコンポーネントがある場合、どうなるか見てみましょう。 このコンポーネントは、最初のコンポーネントとは異なる方法でアイテムを表示します。

Stale-while-refreshを別のコンポーネントに追加する

これは、最初のコンポーネントからロジックをコピーするだけで実行できます。 2番目のコンポーネントは、猫のリストを示しています。

 const CACHE = {}; export default class Component2 extends React.Component { state = { cats: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ cats: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/cats?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ cats: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const cats = this.state.cats.map(cat => ( <p key={cat.id} style={{ background: cat.color, padding: "4px", width: 240 }} > {cat.name} (born {cat.year}) </p> )); return <div>{cats}</div>; } }

ご覧のとおり、ここに含まれるコンポーネントロジックは、最初のコンポーネントとほとんど同じです。 唯一の違いは、要求されたエンドポイントにあり、リストアイテムの表示が異なることです。

ここで、これら両方のコンポーネントを並べて示します。 あなたはそれらが同じように振る舞うのを見ることができます:

2つのコンポーネントを並べて切り替えるアニメーション。

この結果を達成するには、多くのコード複製を行う必要がありました。 このような複数のコンポーネントがある場合、重複するコードが多すぎます。

重複しない方法でそれを解決するために、データをフェッチしてキャッシュし、それを小道具として渡すための高階コンポーネントを使用できます。 理想的ではありませんが、機能します。 ただし、1つのコンポーネントで複数のリクエストを実行する必要がある場合、複数の高次コンポーネントを使用すると、すぐに醜くなります。

次に、レンダリングプロップパターンがあります。これは、クラスコンポーネントでこれを行うためのおそらく最良の方法です。 これは完全に機能しますが、繰り返しになりますが、「ラッパー地獄」になりがちであり、現在のコンテキストをバインドする必要がある場合があります。 これは優れた開発者エクスペリエンスではなく、フラストレーションやバグにつながる可能性があります。

これは、Reactフックがその日を救う場所です。 コンポーネントロジックを再利用可能なコンテナにボックス化して、複数の場所で使用できるようにします。 ReactフックはReact16.8で導入され、関数コンポーネントでのみ機能します。 Reactのキャッシュ制御(特にフックを使用したコンテンツのキャッシュ)に入る前に、まず関数コンポーネントで単純なデータフェッチを行う方法を見てみましょう。

関数コンポーネントでのAPIデータフェッチ

関数コンポーネントでAPIデータをフェッチするには、 useEffect useStateを使用します。

useStateは、クラスコンポーネントのstateおよびsetStateに類似しています。 このフックを使用して、関数コンポーネント内に状態のアトミックコンテナを作成します。

useEffectはライフサイクルフックであり、 componentDidMountcomponentDidUpdate 、およびcomponentWillUnmountの組み合わせと考えることができます。 useEffectに渡される2番目のパラメーターは、依存関係配列と呼ばれます。 依存関係の配列が変更されると、 useEffectへの最初の引数として渡されたコールバックが再度実行されます。

これらのフックを使用してデータフェッチを実装する方法は次のとおりです。

 import React, { useState, useEffect } from "react"; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { fetch(`https://reqres.in/api/users?page=${page}`) .then(res => res.json()) .then(json => { setUsers(json.data); }); }, [page]); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

useEffectへの依存関係としてpageを指定することにより、 pageが変更されるたびにuseEffectコールバックを実行するようにReactに指示します。 これはcomponentDidUpdateと同じです。 また、 useEffectは常に最初に実行されるため、 componentDidMountのようにも機能します。

関数コンポーネントの更新中の失効

useEffectはコンポーネントのライフサイクルメソッドに似ていることがわかっています。 したがって、渡されたコールバック関数を変更して、クラスコンポーネントにあったstale-while-refreshキャッシングを作成できます。 useEffectフックを除いて、すべて同じままです。

 const CACHE = {}; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]); // ... create usersDOM from users return <div>{usersDOM}</div>; }

したがって、関数コンポーネントで機能するstale-while-refreshキャッシングがあります。

2番目のコンポーネントについても同じことができます。つまり、それを関数に変換し、stale-while-refreshキャッシングを実装します。 結果は、クラスで得たものと同じになります。

しかし、それはクラスコンポーネントよりも優れているわけではありませんか? それでは、カスタムフックの機能を使用して、複数のコンポーネントで使用できるモジュール式の更新中の古いロジックを作成する方法を見てみましょう。

カスタムのStale-while-refreshフック

まず、カスタムフックに移動するロジックを絞り込みます。 前のコードを見ると、それがuseStateuseEffectの部分であることがわかります。 具体的には、これがモジュール化するロジックです。

 const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]);

汎用にする必要があるため、URLを動的にする必要があります。 したがって、引数としてurlを使用する必要があります。 複数のリクエストが同じpage値を持つ可能性があるため、キャッシュロジックも更新する必要があります。 幸い、 pageがエンドポイントURLに含まれている場合、一意のリクエストごとに一意の値が生成されます。 したがって、URL全体をキャッシュのキーとして使用できます。

 const [data, setData] = useState([]); useEffect(() => { if (CACHE[url] !== undefined) { setData(CACHE[url]); } apiFetch(url).then(json => { CACHE[url] = json.data; setData(json.data); }); }, [url]);

それはほとんどそれです。 関数内にラップした後、カスタムフックを作成します。 以下をご覧ください。

 const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); }); }, [url]); return data; }

defaultValueという別の引数を追加したことに注意してください。 複数のコンポーネントでこのフックを使用する場合、API呼び出しのデフォルト値は異なる場合があります。 そのため、カスタマイズ可能にしています。

newDataオブジェクトのdataキーについても同じことができます。 カスタムフックがさまざまなデータを返す場合は、 newDataではなくnewData.dataのみを返し、コンポーネント側でそのトラバーサルを処理することをお勧めします。

これで、更新中の古いキャッシュを大幅にリフティングするカスタムフックができました。これが、コンポーネントにプラグインする方法です。 削減できたコードの量に注目してください。 コンポーネント全体が3つのステートメントになりました。 それは大きな勝利です。

 import useStaleRefresh from "./useStaleRefresh"; export default function Component({ page }) { const users = useStaleRefresh(`https://reqres.in/api/users?page=${page}`, []); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

2番目のコンポーネントについても同じことができます。 次のようになります。

 export default function Component2({ page }) { const cats = useStaleRefresh(`https://reqres.in/api/cats?page=${page}`, []); // ... create catsDOM from cats return <div>{catsDOM}</div>; }

このフックを使用すると、ボイラープレートコードをどれだけ節約できるかを簡単に確認できます。 コードも見栄えがします。 アプリ全体の動作を確認したい場合は、このCodeSandboxにアクセスしてください。

useStaleRefreshに読み込みインジケーターを追加する

基本がわかったので、カスタムフックに機能を追加できます。 たとえば、一意のリクエストが送信され、その間に表示するキャッシュがない場合は常にtrueとなるisLoading値をフックに追加できます。

これを行うには、 isLoadingに個別の状態を設定し、フックの状態に応じて設定します。 つまり、キャッシュされたWebコンテンツが利用できない場合は、 trueに設定し、それ以外の場合はfalseに設定します。

更新されたフックは次のとおりです。

 export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); setLoading(false); }); }, [url]); return [data, isLoading]; }

これで、コンポーネントで新しいisLoading値を使用できます。

 export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( `https://reqres.in/api/users?page=${page}`, [] ); if (isLoading) { return <div>Loading</div>; } // ... create usersDOM from users return <div>{usersDOM}</div>; }

これが完了すると、一意のリクエストが初めて送信され、キャッシュが存在しないときに「読み込み中」のテキストが表示されることに注意してください。

読み込みインジケーターが実装されたコンポーネントを示すアニメーション。

useStaleRefresh任意のasync関数をサポートするようにする

GETネットワークリクエストだけでなく、任意のasync関数をサポートするようにすることで、カスタムフックをさらに強力にすることができます。 その背後にある基本的な考え方は変わりません。

  1. フックでは、しばらくすると値を返す非同期関数を呼び出します。
  2. 非同期関数への一意の呼び出しはそれぞれ適切にキャッシュされます。

function.nameargumentsの単純な連結は、ユースケースのキャッシュキーとして機能します。 これを使用すると、フックは次のようになります。

 import { useState, useEffect, useRef } from "react"; import isEqual from "lodash/isEqual"; const CACHE = {}; export default function useStaleRefresh(fn, args, defaultValue = []) { const prevArgs = useRef(null); const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // args is an object so deep compare to rule out false changes if (isEqual(args, prevArgs.current)) { return; } // cacheID is how a cache is identified against a unique request const cacheID = hashArgs(fn.name, ...args); // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data fn(...args).then(newData => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [args, fn]); useEffect(() => { prevArgs.current = args; }); return [data, isLoading]; } function hashArgs(...args) { return args.reduce((acc, arg) => stringify(arg) + ":" + acc, ""); } function stringify(val) { return typeof val === "object" ? JSON.stringify(val) : String(val); }

ご覧のとおり、関数名とその文字列化された引数の組み合わせを使用して、関数呼び出しを一意に識別し、キャッシュします。 これは単純なアプリでは機能しますが、このアルゴリズムは衝突や比較の速度が低下する傾向があります。 (シリアル化できない引数を使用すると、まったく機能しません。)したがって、実際のアプリの場合は、適切なハッシュアルゴリズムの方が適切です。

ここで注意すべきもう1つのことは、 useRefの使用です。 useRefは、囲んでいるコンポーネントのライフサイクル全体を通してデータを永続化するために使用されます。 argsはJavaScriptのオブジェクトである配列であるため、フックを使用してコンポーネントを再レンダリングするたびに、 args参照ポインターが変更されます。 ただし、 argsは最初のuseEffectの依存関係リストの一部です。 したがって、 argsを変更すると、何も変更されていない場合でもuseEffectが実行される可能性があります。 これに対抗するために、isEqualを使用して古いargsと現在の引数を詳細に比較し、 argsが実際に変更された場合にのみuseEffectコールバックを実行します。

これで、この新しいuseStaleRefreshフックを次のように使用できます。 ここでdefaultValueの変更に注意してください。 これは汎用フックであるため、応答オブジェクトのdataキーを返すためにフックに依存していません。

 export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( apiFetch, [`https://reqres.in/api/users?page=${page}`], { data: [] } ); if (isLoading) { return <div>Loading</div>; } const usersDOM = users.data.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

このCodeSandboxでコード全体を見つけることができます。

ユーザーを待たせないでください:更新中の古いフックとReactフックでキャッシュコンテンツを効果的に使用します

この記事で作成したuseStaleRefreshフックは、Reactフックで何が可能かを示す概念実証です。 コードを試して、アプリケーションに適合できるかどうかを確認してください。

または、swrやreact-queryなどの人気のある手入れの行き届いたオープンソースライブラリを介して、stale-while-refreshを活用することもできます。 どちらも強力なライブラリであり、APIリクエストを支援する多くの機能をサポートしています。

ReactHooksはゲームチェンジャーです。 これにより、コンポーネントロジックをエレガントに共有できます。 これは、コンポーネントの状態、ライフサイクルメソッド、およびレンダリングがすべて1つのエンティティ(クラスコンポーネント)にパッケージ化されていたため、以前は不可能でした。 これで、それらすべてに異なるモジュールを使用できます。 これは、構成可能性とより良いコードの記述に最適です。 私は、作成するすべての新しいReactコードに関数コンポーネントとフックを使用しています。これは、すべてのReact開発者に強くお勧めします。

関連: ReduxToolkitとRTKクエリを使用したReactアプリの作成