3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[React] SWRの使い方基礎

Posted at

はじめに

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

こんな感じの作ります↓

スクリーンショット 2022-12-25 1.09.35.png

説明する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)を取得することができます。(フックから何を返すかは自由です(後述します))

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

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

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を使用します。

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は下記条件を指定することができるので、pageper_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リクエストがかけられます。

App.tsx
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を変更することで一覧が更新されます。

スクリーンショット 2022-12-14 11.40.17.png

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,
    }))
  }}
/>

下記のように、前ページ、次ページに遷移するたびに一覧が更新されます。
フックから返ってきたisLoadingtrueの間は、[読み込み中...]を表示しています。

pagination2.gif

3. ページングパラメータをSWRで状態管理する

SWRはデータ取得ライブラリではありますが、ReduxuseContextのように各コンポーネントからアクセス可能なデータを一元管理させることができます。
あるコンポーネントからデータにアクセスしたい場合はこのように書きます。

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コンポーネントとして切り出しておくと見通しがよくなりそうです。

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

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のグローバル設定としてSWRConfigvalueに設定する

<SWRConfig value={{ provider: localStorageProvider }}>
  <App/>
</SWRConfig>

基本これだけで、キャッシュは永続化されます。
画面をリロードしてもページ番号は3で維持されています。↓
reload.gif

4-1. キャッシュすべてを永続化したいわけじゃない

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

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

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

  • 変更後のlocalStorageの内容
    永続化対象であるsearchParamsのみが格納されている。
    スクリーンショット 2022-12-20 23.56.43.png

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

使ってみた感想

メリット

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

デメリット

  • React18と組み合わせたとき、不具合が結構ありそう
  • SWRConfigオプションでキャッシュ設定ができるけど何がベストなのかよくわからない
    • 設定に対して慎重になりすぎるとキャッシュされないし、大胆に設定してしまうとキャッシュが更新されなくなるのでバランスが難しい
  • サーバーから返されたデータに対し、フロントで重い計算ロジックがある場合、相性悪いかも?

おしまい!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?