はじめに
この記事は、SWR(stale-while-revalidate)の基本的な使い方について、Qiita新着記事一覧ページの実装を通して、使い方の基本を共有する記事です。
こんな感じの作ります↓
説明するSWR基本機能
・データFetch
・データをサーバーからGETしてキャッシュする
・グローバル状態管理
・ReduxやRecoilのようにコンポーネント変数をグローバルに状態管理する
・状態管理データの永続化
・画面リロードしても値を維持する
検証環境
React:17.0.2
swr: 1.3.0
typescript: 4.9.3
目次
SWR(stale-while-revalidate)とは
SWRはデータ取得のためのReact Hooksライブラリです。取得したデータはキャッシュしてくれます。その際、裏でデータを再検証し、古ければキャッシュを更新しておいてくれます。次に問い合わせたときは新しいデータを返してくれるわけですね。キャッシュを返してくれるので高速であり、データの鮮度に関しても裏で検証しておいてくれるので古くならないというわけです。
ちなみに、みんな大好きNext.js と同じ開発チームによって作られています。
新着記事一覧ページ作成
1. Qiita API v2から新着記事を取得する
SWRでデータを取得する場合、データを使用するコンポーネント内でデータ取得用フックを呼び出します。(useQiitaItemsState()
は後述)
1行追加するだけで、データ取得、キャッシュ化、そしてローディング状態(isLoading
)を取得することができます。(フックから何を返すかは自由です(後述します))
const App = () => {
...
// data: 取得したデータ
// isLoading: データ取得中かどうか (取得中:true / 得完了:false)
const {data: qiitaItems, isLoading} = useQiitaItemsState()
...
return (
...
)
素直にAPIを呼び出してデータを取得する場合、下記のように書くことになるので、これが1行ロードするのみというのは強力です。
const App = () => {
...
const [items, setItems] = useState<型>()
const getQiitaItems = async () => {
try {
// データ取得
const response = await qiitaRepository.getItems()
// 取得データをセット
setItems(response.data)
} catch(error) {
// error
}
}
useEffect(() => {
getQiitaItems()
},[])
...
return (
...
)
1-1. データ取得用のフックを作成する
useQiitaItemsState()
の中身を見ていきましょう。
SWRでデータ取得する場合、useSWR
を使用します。
useSWR(key, fetcher)
// key: アプリケーションで一意となるキー名を指定
// fetcher: APIにGetリクエストをかけてサーバーからデータを取得する関数を指定
こんな感じの使い方↓
export const useQiitaItemsState = () => {
const res = useSWR('QiitaItems', qiitaRepository.getItems)
return {
...res,
isLoading: res.isValidating || !res.data,
// 再検証中でデータがundefinedの場合はローディング中とする
}
}
useSWR
の返り値は下記の通りで、dataだけでなく、errorや再検証中(isValidating)かを返してくれます。必要なものを返してあげればよいですが、ローディング状態(isLoading
)を返すケースが多いようです。
name | 説明 |
---|---|
data | fetcher によって解決された、指定されたキーのデータ(もしくは、ロードされていない場合は undefined) |
error | fetcher によって投げられたエラー (もしくは undefined) |
isValidating | リクエストまたは再検証の読み込みがある場合 |
mutate(data?, options?) | キャッシュされたデータを更新する関数 |
1-2. フックにパラメータを渡す
SWRに取得条件を指定するケースを見ていきます。記事取得APIは下記条件を指定することができるので、page
とper_page
をリクエストにのせてみます。
parameter | description | example | type | pattern |
---|---|---|---|---|
page | ページ番号 | 1 | string | /^[0-9]+$/ |
per_page | 1ページあたりに含まれる要素数(1から100まで) | 20 | string | /^[0-9]+$/ |
query | 検索クエリ | "qiita user:Qiita" | string | - |
pageとperPageを指定できるように、これらをsearchParamsとして状態管理します。これをフックの引数に指定することで、searchParamsが変更されるたびにGETリクエストがかけられます。
const App = () => {
...
const [searchParams, setSearchParams] = useState({
page: 1,
perPage: 20,
})
const {data: qiitaItems, isLoading} = useQiitaItemsState(searchParams)
...
return (
...
)
useQiitaItemsState
は取得パラメータを引き受けるようにこんな感じ↓に修正します。
詳細は、公式サイトに記載があります。
export const useQiitaItemsState = (props: Props) => {
const res = useSWR(['QiitaItems', props], (_, args) => qiitaRepository.getItems(args))
return {
...res,
isLoading: res.isValidating || !res.data,
}
}
2. ページングを実装する
フックに取得条件を渡せるようにできたので次はページングを配置します。
次ページ、前ページに遷移したときに、pageを変更することで一覧が更新されます。
PaginationコンポーネントのIF
属性 | description |
---|---|
pageNo | ページ番号 |
totalCount | データ全数 |
countPerPage | 1ページあたりに含まれる要素数 |
onPrev | 次ページボタンクリックハンドラ |
onNext | 前ページボタンクリックハンドラ |
<Pagination
pageNo={searchParams.page}
totalCount={totalCount}
countPerPage={COUNT_PER_PAGE}
onPrev={() => {
setSearchParams(prev => ({
...prev,
page: prev.page - 1 < 1 ? prev.page : prev.page - 1
}))
}}
onNext={() => {
setSearchParams(prev => ({
...prev,
page: prev.page + 1 > totalCount ? totalCount : prev.page + 1,
}))
}}
/>
下記のように、前ページ、次ページに遷移するたびに一覧が更新されます。
フックから返ってきたisLoading
がtrue
の間は、[読み込み中...]を表示しています。
3. ページングパラメータをSWRで状態管理する
SWR
はデータ取得ライブラリではありますが、Redux
やuseContext
のように各コンポーネントからアクセス可能なデータを一元管理させることができます。
あるコンポーネントからデータにアクセスしたい場合はこのように書きます。
const { searchParamsState, setSearchParamsState } = useSearchParamsState()
// searchParamsState: 検索パラメータ(←取得したいデータ)
// setSearchParamsState: データ更新関数
大雑把にいうとuseState
のように宣言することで、取得したいデータおよびそれを更新する関数を取得することができます。
useSearchParamsState()
の中身を見ていきましょう。
データ取得のときと同様にフックを作成するのですが、そのときとは下記2点が異なります。
- mutate(データ更新関数)を使用する
- fetcherにはnullを指定する
type SearchParamsType = {
page: number
perPage: number
}
const initialState: SearchParamsType = {
page: 1,
perPage: 20,
}
export const useSearchParamsState = () => {
// mutateが更新用の関数になります。別名をつけてわかりやすくしてます
const { data, mutate: setSearchParamsState } = useSWR(
'searchParams',
null, // fetcherによるデータ取得が生じないのでnull
{
fallbackData: initialState, // 初期値を設定
},
)
// dataがundefinedの場合は初期値を代入
const searchParamsState = data === undefined ? initialState : data
// データと更新用関数を返す
return { searchParamsState, setSearchParamsState }
}
このように管理したいデータをフックとして定義することで、任意のコンポーネントからキャッシュデータにアクセスすることができます。フックを用意するだけなので手軽です。
3-1. ページングを改善する
キャッシュ機能を活用してページングを改善してみます。
ここまででは、ページングを操作するごとに「読み込み中...」が表示されます。これでも特に問題はないですが、いちいち表示されると煩わしい場合もあります。ということで、「読み込み中...」が表示されないように改善してみたいと思います。
SWRはキャッシュとして機能するため、次ページデータを非表示の div 内にレンダリングしておくと、SWR は次のページのデータフェッチを開始し、キャッシュします。そうすることで、ユーザーが1ページ目を表示したときに2ページのデータは取得済みとなり、次ページに遷移したときに「読み込み中...」は表示されず、即座に次ページが表示されることになるという理屈です。
<Page searchParams={searchParamsState} /> // 基準ページを読み込んで一覧を表示する
<div className='hidden'>
<Page searchParams={{
...searchParamsState,
page: searchParamsState.page + 1, // 同時に次ページのデータをフェッチしてキャッシュしておく
}} />
</div>
一覧表示のところはPage
コンポーネントとして切り出しておくと見通しがよくなりそうです。
const Page = ({ searchParams }: { searchParams: SearchParamsType }) => {
const {data: qiitaItems, isLoading} = useQiitaItemsState(searchParams)
const getNumber = (index: number, pageNumber: number) =>
index + 1 + (pageNumber - 1) * COUNT_PER_PAGE
return isLoading ? (
<p>読み込み中...</p>
):(
<>
{qiitaItems.data.map((v, index) => (
<div key={v.id}>
<List>
<Cell>{getNumber(index, searchParams.page)}</Cell>
<Cell>{v.title}</Cell>
<Cell>{v.user.name || 'No Name'}</Cell>
<Cell><a className='text-blue-600' href={v.url} target="_blank" rel="noopener noreferrer">{v.url}</a></Cell>
<Cell>{v.updated_at}</Cell>
</List>
</div>
))}
</>
)
}
↓こんな感じに「読み込み中...」が表示されず、次ページが即座に表示されます。
4. SWRで状態管理しているデータを永続化する
ここまでの実装では、画面をリロードするとページングパラメータは都度初期値にリセットされます。状態管理データをリロードしても保持したいという要件はシステムによくあるので、SWRでデータを永続化する方法を見ていきます。
永続化については、公式サイトのほうにLocalStorage を使った永続キャッシュとして記載されています。
画面から離脱したときにキャッシュをlocalStorageに格納、画面にアクセスしたときにlocalStorageからキャッシュを復元するためにまず下記のlocalStorageProvider
を用意します。
const localStorageProvider = () => {
// 初期化時に、 `localStorage` から Map にデータを復元します。
const map = new Map(JSON.parse(localStorage.getItem('app-cache') || '[]'))
// アプリが終了する前に、すべてのデータを `localStorage` に保存します。
window.addEventListener('beforeunload', () => {
const appCache = JSON.stringify(Array.from(map.entries()))
localStorage.setItem('app-cache', appCache)
})
// パフォーマンスのために、書き込みと読み取りには引き続き Map を使用します。
return map
}
これをSWRのグローバル設定としてSWRConfig
のvalue
に設定する
<SWRConfig value={{ provider: localStorageProvider }}>
<App/>
</SWRConfig>
基本これだけで、キャッシュは永続化されます。
画面をリロードしてもページ番号は3
で維持されています。↓
4-1. キャッシュすべてを永続化したいわけじゃない
プロジェクトでSWRによる状態管理を取り入れた場合、key(今回であればsearchParams
のような)が複数存在するわけですが、すべてのkeyについて永続化したいわけじゃないので、特定のキャッシュのみ永続化する方法についてみます。
検索条件(key=searchParams
)のみを永続化させたい場合、localStorageProvider
を↓こんな感じに修正します。(一例です)
+ const PERSIST_KEY = ['searchParams']
export const localStorageProvider = () => {
// 初期化時に、 `localStorage` から Map にデータを復元します。
const map = new Map(JSON.parse(localStorage.getItem('app-cache') || '[]'))
// アプリが終了する前に、すべてのデータを `localStorage` に保存します。
window.addEventListener('beforeunload', () => {
+ const persistKeyArray: [string, unknown][] = PERSIST_KEY.filter(
+ v => map.get(v),
+ ).map(v => [v, map.get(v)])
+ const persistKeyMap = new Map(persistKeyArray)
+ const appCache = JSON.stringify(Array.from(persistKeyMap.entries()))
- const appCache = JSON.stringify(Array.from(map.entries()))
localStorage.setItem('app-cache', appCache)
})
// パフォーマンスのために、書き込みと読み取りには引き続き Map を使用します。
return map
}
変更点
・ 永続化したいkeyをPERSIST_KEY
として定義
・beforeunload
イベント内で、PERSIST_KEY
に存在するkeyのみをlocalStorageに格納
永続化したいkeyを追加したい場合はPERSIST_KEY
にkeyを追加するだけでOKです。
使ってみた感想
メリット
- 導入が簡単
- reduxと比較すると導入コストが低い印象
-
useState
ように宣言することで任意のコンポーネントからキャッシュデータにアクセスできる、と同時にローディング状態も取得できる - キャッシュ層と状態管理ロジックを同時に導入できる
- 開発チームがNext.jsと同じなので開発に関しては安心
デメリット
- React18と組み合わせたとき、不具合が結構ありそう
-
キャッシュ永続化導入における不具合
- v2で改善されるっぽい?
- React18と一緒に使えないのは致命的
-
キャッシュ永続化導入における不具合
-
SWRConfig
のオプションでキャッシュ設定ができるけど何がベストなのかよくわからない- 設定に対して慎重になりすぎるとキャッシュされないし、大胆に設定してしまうとキャッシュが更新されなくなるのでバランスが難しい
- サーバーから返されたデータに対し、フロントで重い計算ロジックがある場合、相性悪いかも?
おしまい!