[React] SWRの使い方基礎

# はじめに
この記事は、SWR(stale-while-revalidate)の基本的な使い方について、Qiita新着記事一覧ページの実装を通して、使い方の基本を共有する記事です。

こんな感じの作ります↓

![スクリーンショット 2022-12-25 1.09.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/d40e007c-5273-d70d-e21c-01879edc7dea.png)


**説明するSWR基本機能**
・データFetch
　・データをサーバーからGETしてキャッシュする
・グローバル状態管理
　・ReduxやRecoilのようにコンポーネント変数をグローバルに状態管理する
・状態管理データの永続化
　・画面リロードしても値を維持する

# 検証環境
React:17.0.2
swr: 1.3.0
typescript: 4.9.3

# 目次
- [SWR(stale-while-revalidate)とは](#swrstale-while-revalidateとは)
- [新着記事一覧ページ作成](#新着記事一覧ページ作成)
    - [1. Qiita API v2から新規記事を取得する](#1-qiita-api-v2から新着記事を取得する)
        - [1-1. データ取得用のフックを作成する](#1-1-データ取得用のフックを作成する)
        - [1-2. フックに取得条件パラメータを渡す](#1-2-フックにパラメータを渡す)
    - [2. ページングを実装する](#2-ページングを実装する)
    - [3. ページングパラメータをSWRで状態管理する](#3-ページングパラメータをswrで状態管理する)
        - [3-1. ページングを改善する](#3-1-ページングを改善する)
    - [4. SWRで状態管理しているデータを永続化する](#4-swrで状態管理しているデータを永続化する)
        - [4-1. キャッシュすべてを永続化したいわけじゃない](#4-1-キャッシュすべてを永続化したいわけじゃない)
- [使ってみた感想(メリット・デメリット)](#使ってみた感想)

# SWR(stale-while-revalidate)とは

SWRはデータ取得のためのReact Hooksライブラリです。取得したデータはキャッシュしてくれます。その際、裏でデータを再検証し、古ければキャッシュを更新しておいてくれます。次に問い合わせたときは新しいデータを返してくれるわけですね。キャッシュを返してくれるので高速であり、データの鮮度に関しても裏で検証しておいてくれるので古くならないというわけです。
ちなみに、みんな大好きNext.js と同じ開発チームによって作られています。

https://swr.vercel.app/ja

# 新着記事一覧ページ作成

## 1. Qiita API v2から新着記事を取得する
SWRでデータを取得する場合、データを使用するコンポーネント内でデータ取得用フックを呼び出します。（`useQiitaItemsState()`は後述）
１行追加するだけで、データ取得、キャッシュ化、そしてローディング状態（`isLoading`）を取得することができます。（フックから何を返すかは自由です(後述します)）

```tsx:App.tsx
const App = () => {
  ...
  // data: 取得したデータ
  // isLoading: データ取得中かどうか　(取得中：true / 得完了：false)
  const {data: qiitaItems, isLoading} = useQiitaItemsState()
  ...
  return (
    ...
  )
```

素直にAPIを呼び出してデータを取得する場合、下記のように書くことになるので、これが１行ロードするのみというのは強力です。

```tsx:App.tsx
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`を使用します。
```tsx
useSWR(key, fetcher)
// key: アプリケーションで一意となるキー名を指定
// fetcher: APIにGetリクエストをかけてサーバーからデータを取得する関数を指定
```

こんな感じの使い方↓
```tsx
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?)|キャッシュされたデータを更新する関数 |

[参考](https://swr.vercel.app/ja/docs/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リクエストがかけられます。

```tsx:App.tsx
const App = () => {
  ...
  
  const [searchParams, setSearchParams] = useState({
    page: 1,
    perPage: 20,
  })
  const {data: qiitaItems, isLoading} = useQiitaItemsState(searchParams)

  ...
  return (
    ...
  )
```

`useQiitaItemsState`は取得パラメータを引き受けるようにこんな感じ↓に修正します。
詳細は、公式サイトに記載があります。

https://swr.vercel.app/ja/docs/arguments

```tsx
export const useQiitaItemsState = (props: Props) => {

  const res = useSWR(['QiitaItems', props], (_, args) => qiitaRepository.getItems(args))
  return {
    ...res,
    isLoading: res.isValidating || !res.data,
  }
}
```


## 2. ページングを実装する
フックに取得条件を渡せるようにできたので次はページングを配置します。
次ページ、前ページに遷移したときに、pageを変更することで一覧が更新されます。

![スクリーンショット 2022-12-14 11.40.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/0837637a-a4e0-d925-04bd-58083c12ba8c.png)

PaginationコンポーネントのIF

| 属性 | description |
|:-:|:-:|
|pageNo|ページ番号|
|totalCount|データ全数|
|countPerPage|1ページあたりに含まれる要素数|
|onPrev|次ページボタンクリックハンドラ|
|onNext|前ページボタンクリックハンドラ|

```tsx
<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`の間は、[読み込み中...]を表示しています。

![pagination2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/7004040c-21db-d88d-2bbb-648c0bd6d50f.gif)


## 3. ページングパラメータをSWRで状態管理する
`SWR`はデータ取得ライブラリではありますが、`Redux`や`useContext`のように各コンポーネントからアクセス可能なデータを一元管理させることができます。
あるコンポーネントからデータにアクセスしたい場合はこのように書きます。

```tsx
const { searchParamsState, setSearchParamsState } = useSearchParamsState()
// searchParamsState: 検索パラメータ（←取得したいデータ）
// setSearchParamsState: データ更新関数
```
大雑把にいうと`useState`のように宣言することで、取得したいデータおよびそれを更新する関数を取得することができます。

`useSearchParamsState()`の中身を見ていきましょう。
データ取得のときと同様にフックを作成するのですが、そのときとは下記２点が異なります。
- mutate(データ更新関数)を使用する
- fetcherにはnullを指定する


```tsx
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ページ目を表示したときに２ページのデータは取得済みとなり、次ページに遷移したときに「読み込み中...」は表示されず、即座に次ページが表示されることになるという理屈です。

```tsx: こんな感じで次ページをキャッシュしておく
<Page searchParams={searchParamsState} /> // 基準ページを読み込んで一覧を表示する
<div className='hidden'>
  <Page searchParams={{
    ...searchParamsState,
    page: searchParamsState.page + 1, // 同時に次ページのデータをフェッチしてキャッシュしておく
  }} />
</div>
```
一覧表示のところは`Page`コンポーネントとして切り出しておくと見通しがよくなりそうです。

```tsx: 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>   
      ))}
    </>
  )
}
```

↓こんな感じに「読み込み中...」が表示されず、次ページが即座に表示されます。

![pagination_mod.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/a87236ff-de2c-91b1-a331-f5731766539c.gif)


## 4. SWRで状態管理しているデータを永続化する
ここまでの実装では、画面をリロードするとページングパラメータは都度初期値にリセットされます。状態管理データをリロードしても保持したいという要件はシステムによくあるので、SWRでデータを永続化する方法を見ていきます。
永続化については、公式サイトのほうに[LocalStorage を使った永続キャッシュ](https://swr.vercel.app/ja/docs/advanced/cache#localstorage-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E6%B0%B8%E7%B6%9A%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5)として記載されています。

画面から離脱したときにキャッシュをlocalStorageに格納、画面にアクセスしたときにlocalStorageからキャッシュを復元するためにまず下記の`localStorageProvider`を用意します。
```tsx
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`に設定する
```tsx
<SWRConfig value={{ provider: localStorageProvider }}>
  <App/>
</SWRConfig>
```
基本これだけで、キャッシュは永続化されます。
画面をリロードしてもページ番号は`3`で維持されています。↓
![reload.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/e6e48be0-6361-dd7f-f3cf-c5dd42f61241.gif)

### 4-1. キャッシュすべてを永続化したいわけじゃない
プロジェクトでSWRによる状態管理を取り入れた場合、key(今回であれば`searchParams`のような)が複数存在するわけですが、すべてのkeyについて永続化したいわけじゃないので、特定のキャッシュのみ永続化する方法についてみます。

検索条件(key=`searchParams`)のみを永続化させたい場合、`localStorageProvider`を↓こんな感じに修正します。(一例です)

```diff: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に格納

- 変更前のlocalStorageの内容
永続化対象である`searchParams`に加え、key=`QiitaItems`も格納されている。
![スクリーンショット 2022-12-20 23.54.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/8031e24c-30f6-9437-1f8b-06f5d5bbc881.png)

- 変更後のlocalStorageの内容
永続化対象である`searchParams`のみが格納されている。
![スクリーンショット 2022-12-20 23.56.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986224/00a703e9-bda6-72ee-5b11-42f94617e499.png)

永続化したいkeyを追加したい場合は`PERSIST_KEY`にkeyを追加するだけでOKです。


# 使ってみた感想
**メリット**
- 導入が簡単
    - reduxと比較すると導入コストが低い印象
- `useState`ように宣言することで任意のコンポーネントからキャッシュデータにアクセスできる、と同時にローディング状態も取得できる
- キャッシュ層と状態管理ロジックを同時に導入できる
- 開発チームがNext.jsと同じなので開発に関しては安心

**デメリット**
- React18と組み合わせたとき、不具合が結構ありそう
    - [キャッシュ永続化導入における不具合](https://github.com/vercel/swr/issues/1904)
        - v2で改善されるっぽい？
        - React18と一緒に使えないのは致命的
- `SWRConfig`の[オプション](https://swr.vercel.app/ja/docs/api)でキャッシュ設定ができるけど何がベストなのかよくわからない
    - 設定に対して慎重になりすぎるとキャッシュされないし、大胆に設定してしまうとキャッシュが更新されなくなるのでバランスが難しい
- サーバーから返されたデータに対し、フロントで重い計算ロジックがある場合、相性悪いかも？


おしまい！