【React】useSWRInfiniteを使って追加ローディングボタンを作る
はじめに
はじめまして。今年の5月から入社したヨシです。普段は主にフロントエンドでの開発に携わらせて頂いています (たまに Laravel なども触ります)。
今回はじめての開発ブログの記事となりますが、普段は個人での技術記事やブログなどを趣味で書いているので、なるべく気負わずに楽しく書いていきたいなと思っていますのでよろしくお願いします。
この記事では、現在開発中のプロジェクトで出会った SWR の useSWRInfinite フックについて書いてみたいと思います。実は useSWRInfinite の使い方自体は公式ドキュメントに記載してあるとおりに行えば簡単に利用できますが、このフックに渡す getKey 関数の使い方がいまいち分かりづらく、すこしハマったでので特にそれについての知見を書いていきたいと思います。
環境
- Next.js: 13.3.4
- React: 18.2.0
- SWR: 2.2.2
SWR とは
まずはじめに SWR とはなにかを説明しておくと、SWR は React で利用できるデータ取得のための React Hooks のライブラリです。SWR を使うことで React での非同期のデータ取得を useEffect を利用することなく、簡単に扱えるようになります。
リクエストには “loading”、“ready”、“error” という3つの状態があり、以下のようにそれらの状態に対応する data、error、isLoading を分割代入で useSWR フックから取得して、条件によってレンダリングするものを指定してあげます。
import useSWR from "swr";
function App() {
const { data, error, isLoading } = useSWR(`/api/user/${userId}`, fetcher);
if (error) return <div>ロード失敗...</div>;
if (isLoading) return <div>ローディング中...</div>;
// データフェッチに成功してローディングが完了すればデータを表示
return <div>hello {data.name}</div>;
}
useSWRInfinite
SWR ライブラリで利用できる基本のフックである useSWR については上記のような使い方を行いますが、今回の記事では、ページネーションや無限スクロールなどのパターンで利用できる useSWRInfinite フックについて見ていきます。
実際の業務ではチャット画面のフロントエンドを実装するというものでしたが、表示できるチャットメッセージの個数を制限して、過去のログを読みたい場合にはボタンクリックによって追加ローディングを行うという仕様でした。useSWRInfinite はこのような機能の実装にはもってこいです。
SWR に付属しているこのフックを利用することで「更に読み込む」ボタンなどのクリックによる追加でのデータローディングを比較的簡単に実装することができます。使い方としては、通常の useSWR と同様にコンポーネント内でフックの利用を宣言して分割代入などで使いたい data や isLoading などのオブジェクトを取得します。このとき useSWRInfinite 特有のものとして size と setSize がフックから提供されます。size はフィッチによって返されるページ数であり、setSize はフェッチする必要のあるページ数を制御します。
import useSWRInfinite from "swr/infinite";
function App() {
const { data, error, isLoading, size, setSize } = useSWRInfinite(
getKey,
fetcher,
);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div>
<div>
{data.map((users) => {
return users.map((user) => <div key={user.id}>{user.name}</div>);
})}
</div>
<button onClick={() => setSize(size + 1)}>さらに読み込む</button>
</div>
);
}
対象となる API の形式によって細かいところは変わってきますが、基本的な使い方は上の通りで、setSize のメソッドによってフィッチのページ数が増やされることをトリガーに追加のフェッチが行われるという仕組みです。
getKey について
さて、本題に入りますが、useSWRInfinite の使い方は最初に述べた通り公式ドキュメントに記載してあるやり方を行えば簡単に利用できますが、getKey の使い方がいまいち分かりづらく、今回すこしハマったでのでそれについての知見を書いていきたいと思います。
useSWRInfinite を使う上で厄介だったのが第一引数の getKey の扱いで、通常の useSWR では第一引数には単に key となる文字列・配列などをシンプルに渡せばよいのですが、getKey には必ず関数を渡す必要があります。そして getKey から返される値は useSWRInfinite の第二引数である fetcher 関数の引数として渡されます。
今回、担当した業務での API の形式はいわゆるカーソル形式であり、SWR 公式ドキュメントの該当ページの例2として記載されているパターンで、取得するチャットメッセージにはそれぞれ ID がふってあり、取得したチャットメッセージ内で一番古いメッセージの ID を API コール時のクエリパラメータとして指定することでそのメッセージよりも古いメッセージを追加で取得します。
フェッチによって例えば以下のようなデータが返ってきます。
// GET /api/messages/{userId}
{
"messages": [
{
"id": "333f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "田中太郎",
"sentAt": "2023-09-20T12:00:00.000Z",
"textBody": "業務可能な曜日を教えてください。"
},
{
"id": "444f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "山田花子",
"sentAt": "2023-09-20T13:00:00.000Z",
"textBody": "火曜日と木曜日です!"
}
]
}
上記の例では、すでに取得したメッセージの最後のメッセージの ID(333f042e-40a8-4c9d-91c0-421460b80856)をクエリパラメータとして before に指定して API コールすることで、現在表示されているメッセージよりも過去のメッセージを取得できるように設計されているとします。
// GET /api/messages/{userId}?before=333f042e-40a8-4c9d-91c0-421460b80856
{
"messages": [
{
"id": "111f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "田中太郎",
"sentAt": "2023-09-20T10:00:00.000Z",
"textBody": "こんにちは!よろしくお願いします。"
},
{
"id": "222f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "山田花子",
"sentAt": "2023-09-20T11:00:00.000Z",
"textBody": "こちらこそよろしくお願いします。"
}
]
}
今回のケースではチャットの初期表示されるメッセージ数は 2 件(実際には 50 件)として「もっと見る」ボタンをクリックすることで追加で 2 件の読み込みを行って表示するような機能を考えます。
ただし、このプロジェクトでは OpenAPI を使った API 定義から openapi-generator というツールを使って fetcher となる API 用のメソッドや型定義を TypeScript (+Axios) 向けに自動生成しています。従って、以下のような型定義が事前に用意されているわけですが、画面上で利用する API を listMessages というメソッドだとして、クエリパラメータを作成するためのオブジェクトを渡す必要があることがわかります。
export interface DefaultApiListMessagesRequest {
readonly userId: string;
readonly before?: string;
}
declare const listMessages: (
requestParameters: DefaultApiListMessagesRequest,
options?: AxiosRequestConfig<any> | undefined,
) => Promise<ListMessages200Response>;
useSWRInfinite の第二引数の fetcher には getKey で返した値が直接渡るのですが、ここではうまく以下のような形式に落ち着けたいです。
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.messages.length) return null;
if (pageIndex === 0) {
return {
key: "listMessages",
param: {
userId: String(router.query.id),
},
};
}
return {
key: "listMessages",
param: {
userId: String(router.query.id),
before: previousPageData?.messages.at(-1)?.id,
},
};
};
const { data, isLoading, size, setSize } = useSWRInfinite(
getKey,
(key) => listMessages(key.param),
);
もちろん公式ドキュメントのサンプルに記載されているのは、以下のように getKey からパス文字列を返すというやり方のみです。
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.length) {
return null; // 最後に到達した
}
return `/users?page=${pageIndex}&limit=10`; // SWR キー
};
fetcher が単にパスを受けとってそのパスへのリクエストを送るというものなら分かりやすいですが、今回は生成された API メソッドにクエリパラメータとなるオブジェクトを渡すというやり方がしたいです。
ですが、getKey からはそもそもオブジェクトを返すなどのことをやってもよいのでしょうか?なんとくなくできそうな雰囲気ではありますが、その保証については実は TypeScript の型定義を見ることで判別できます。
getKey から返されるキーはリクエストのためのユニークな値ですが、この key 自体は単に fetcher への引数として渡されるだけなので、実際に文字列でなくても OK なのです。getKey への型付けは SWR から提供されている以下の SWRInfiniteKeyLoader というジェネリクス型を利用できます。
type SWRInfiniteKeyLoader<Data = any, Args extends Arguments = Arguments> = (
index: number,
previousPageData: Data | null,
) => Args;
type Arguments =
| string
| ArgumentsTuple
| Record<any, any>
| null
| undefined
| false;
type ArgumentsTuple = readonly [any, ...unknown[]];
第一型引数には API から取得できるデータの型を指定し、第二型引数には getKey から返されるキーの型を指定します。それぞれ指定しない場合には、第一型引数は any 型に、第二型引数は SWR から提供される上記の Arguments という型がデフォルト型引数となります。型引数を指定する場合でも Arguments 型の部分型を指定する必要があります。この型を見る限り、getKey から返される型はタプル型やオブジェクト型などが認められていることがわかります。
実際にオブジェクトをキーとして返したい場合には例えば以下のようなコードを書けばよいです(実際の実装の一部を簡略化したコードです)。ローディングで最後までデータをフェッチしてこれ以上読み込めないことを表現するには getKey において、条件判定で null を返すようにします。null キーとして返ってくる場合にはページのリクエストが行われなくなります。従って、データを最後まで読み込んだ場合のために null を返させたいので SWRInfiniteKyeLoader の第二型引数には null とのユニオン型を指定しています。
type InfiniteKeyValue = {
key: string;
param: DefaultApiListMessagesRequest;
};
// ...
const Messages = () => {
// この実装では、ページの URL からパスパラメータとなっているIDを取得してクエリパラメータに使いたいので Next.js の useRouter を使用しています
const router = useRouter();
const getKey: SWRInfiniteKeyLoader<
ListMessages200Response,
InfiniteKeyValue | null
> = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.messages.length) return null;
if (pageIndex === 0) {
return {
key: "listMessages",
param: {
userId: String(router.query.id),
},
};
}
return {
key: "listMessages",
param: {
userId: String(router.query.id),
before: previousPageData?.messages.at(-1)?.id,
},
};
};
const { data, isLoading, size, setSize } = useSWRInfinite(
getKey,
(key) => listMessages(key.param),
);
return (
<div>
<div>
<button
type="button"
onClick={() => {
void setSize(size + 1);
}}
>
もっと見る
</button>
</div>
{/* ... */}
</div>
);
};
これで OpenAPI から自動生成した API のメソッドでも useSWR を使って追加のローディングができるようになりました。
ちなみに、getKey の第二引数となる previousPageData と useSWRInfinite から返ってくる data オブジェクトについても少し注意が必要で、data オブジェクトは previousPageData の配列の形式になっています。
// dataの形式 = APIレスポンスの配列
[
{
"messages": [
{
"id": "111f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "田中太郎",
"sentAt": "2023-09-20T10:00:00.000Z",
"textBody": "こんにちは!よろしくお願いします。"
},
{
"id": "222f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "山田花子",
"sentAt": "2023-09-20T11:00:00.000Z",
"textBody": "こちらこそよろしくお願いします。"
}
]
},
{
"messages": [
{
"id": "333f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "田中太郎",
"sentAt": "2023-09-20T12:00:00.000Z",
"textBody": "業務可能な曜日を教えてください。"
},
{
"id": "444f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "山田花子",
"sentAt": "2023-09-20T13:00:00.000Z",
"textBody": "火曜日と木曜日です!"
}
]
}
]
// previousDataの形式 = 元々のAPIレスポンス
{
"messages": [
{
"id": "333f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "田中太郎",
"sentAt": "2023-09-20T12:00:00.000Z",
"textBody": "業務可能な曜日を教えてください。"
},
{
"id": "444f042e-40a8-4c9d-91c0-421460b80856",
"nickName": "山田花子",
"sentAt": "2023-09-20T13:00:00.000Z",
"textBody": "火曜日と木曜日です!"
}
]
}
このように data オブジェクトの形式は API レスポンスの配列となっているため、map メソッドを使って繰り返し表示する際には気をつけるようにしてください。
まとめ
さて、useSWRInfinite を使っての追加ローディングの方法および、getKey でのちょっとしたハマりポイントについての解説でしたが、いかがでしたでしょうか?
個人的な教訓としては、公式ドキュメントだけでなくしっかりと型定義を読むのが重要だなと感じました。今回のケースでは特に SWRInfiniteKeyLoader の第二型引数を見ることでキーとなる型は比較的なんでも良いことが理解できました。公式ドキュメントのサンプルだけみてもこのやり方ができるか自信が持てず時間を浪費してしまいましたが、型定義をしっかり見ればすぐに解決できたでしょう。
このような TypeScript の型定義についてはライブラリのドキュメントにおいて不足している部分が多くあるので、正しい使い方を知るためには型定義も読むように心がけたいなと思います。
- TypeScript , Next.js , React