SWRを使ってハマったところ
SWRとは
Vercelが開発する、HTTP RFC 5861で提唱された、SWRというキャッシュ無効化戦略に基づくライブラリ。
簡単に言うと、最初は普通にデータを取得してキャッシュとしてセット、次に参照された時に一旦キャッシュを返して、裏でまたフェッチして、フェッチが完了したらキャッシュを最新のものに置き換えるというキャッシュ戦略をよしなにやってくれる。
個人的に理解が浅くてハマったところを紹介する。
SWRを使ったローディング状態の管理のハマりどころ
Axiosの例
通常axiosなどをそのまま使う場合はloadingのstateは自前で管理するだろう。
(コードは超適当)
function useUserByAxios() {
const [loading, setLoading] = useState(false)
const [user, setUser] = useState(undefined)
useEffect(() => {
setLoading(true)
setUser(await axios.get('/api/users/123'))
setLoading(false)
}, [])
return {
data,
loading
}
}
SWRの例
SWRを使用してデータをfetchする場合、このようにloadingの管理をすることになる。
これは公式にも書いてある例である。
function useUserBySWR() {
const { data, error } = useSWR('/api/users/123', fetcher)
const loading = !data
return {
data,
error,
loading
}
}
ハマりどころ
ここでまずハマるのが、SWRの方のloadingは一度データを取得したらもう二度とtrueにならないことだ。
どういうことかというと、axiosの例の方は、useUserを使用しているコンポーネントがmountされるたびに、(useEffectが動くたびに) APIを必ず叩くので、しっかりとloadingが一度trueになる。
これに対して、SWRの方は二度目にマウントされた時には前回のdataが残っている(キャッシュされている)のでconst loading = !data
で定義してあるloadingはtrueにならない。
これはユーザー体験的、パフォーマンス的にはとても良い。
例えばこのようなコードがあれば、ユーザーはローディングを初回しか見ずに済む。
const { loading, user } = useUserBySWR()
if(loading) return <Loading />
return <User user={user} />
2回目以降は一瞬古いユーザーの情報が表示されて、fetchが完了次第すぐに最新のユーザー情報に切り替わる。
単にloadingをLoadingコンポーネントの出し分けのみに使うならとても良い。(古い情報が一瞬出るという点を許容できるなら)
問題のある使い方
しかし、loadingをビジネスロジックに関わるような扱い方をしたい場合は少し困る。
例えば、ページ遷移である。
// ログイン後のページの高階コンポーネント
export default function Private({ children }) {
const { loading, notice } = useNoticeBySWR()
useEffect(() => {
if(loading) return
if(notice) router.push('/notice')
},[loading, notice])
if(loading) return <Loading />
return <>{children}</>
}
これはユーザーがログインした時点で未読のお知らせがあれば、お知らせページに飛んで欲しいというものである。
まぁuseEffect使うなみたいな議論はさておき。
これだけだとユーザーが以下のように動くと問題が起きる
- ログインする
- noticeが帰ってきてユーザーはお知らせページに飛ばされ、ユーザーが既読をつける
- ログアウトする
- 再度ログインする
すると、このPrivateがまたレンダリングされるわけだが、useNoticeBySWRはnoticeのキャッシュを持っている。
よって、useEffect内のif(loading) returnが効かない。
noticeがあるのでそのままユーザーはなぜか既読のお知らせに飛ばされる。
しかも、if(loading) return <Loading />も効かないので一瞬ログイン後のダッシュボード画面かなんかがちらついて、その後にお知らせに飛ばされることになる。
解決方法
1. mutateを使う
シンプルな話、古いキャッシュが残ってしまっていることが原因である。
この例のようにレンダリング前に実行しておきたいロジック(ページの出しわけや認証など)は再検証前のキャッシュを使われると困ることがある。
どう回避するかというと、やり方は色々あるが先ほどのお知らせの例のように、もう絶対に使わないようなデータをキャッシュしておくのは無駄だし、ログイン後に取得したデータのキャッシュがログアウト後に残っているのは危険だ。
なのでSWRに忘れてもらおう。
export default function Notice() {
const { mutate } = useNoticeBySWR()
useEffect(() => {
readNoticeRequest()
mutate(null, false)
}, [])
//...
}
(mutateも、おそらくreadNoticeRequestもPromiseを返すので本当はuseEffectの中身を(async(){})()で囲む必要があるがわかりやすさのために省略した)
mutateとはキャッシュの内容を変更するメソッドである。
このコードはuseNoticeBySWRが返すnoticeをnullに変更し、第二引数でfalseを渡して、revalidate(再検証、ここでは再度APIを叩くこと)しないという内容である。
ここでまた一つ落とし穴があるのだが、mutateの第一引数は省略可能、つまりデフォルト値がundefinedなのでundefinedを渡すと普通に無視される(キャッシュをundefinedにはできない)
ので仕方なくnullを入れている。今度issueあげてみようかなこれ。
2022年1月24日追記:上記のバグは以下のPRで修正されていました。
もちろんAPIがnullishな値を返すなら普通に再検証しても良い。はず。
2. isValidatingを使う
isValidatingとはuseSWRが返す、再検証中かどうかのフラグである。
前述した通り、SWRはキャッシュを返しつつ、裏で再検証するので、再検証中ならloadingとすることで同じことが可能である。
function useNoticeBySWR() {
const { data, error, isValidating } = useSWR('/api/notice/123', fetcher)
const loading = isValidating
return {
data: notice,
loading
}
}
// ログイン後のページの高階コンポーネント
export default function Private({ children }) {
const { isValidating, notice } = useNoticeBySWR()
useEffect(() => {
if(isValidating) return
if(notice) router.push('/notice')
},[isValidating, notice])
if(isValidating) return <Loading />
return <>{children}</>
}
ただ、SWRは分割代入で受け取る変数によってパフォーマンス最適化が行われているため、なるべくならmutateの方で対策した方が良い。
さらにキャッシュ自体がなくなるわけではないので、キャッシュが残っているせいで何かが起きるという危険性は依然としてある。気をつけよう。まとめ
個人的にSWRは今仕事で使っていて、とても要件にマッチしていて良いと感じている。
麻薬は用法用量を守って正しく使う必要がある。
mutateをうまく使って、正しくキャッシュと向き合っていこう。
まだまだハマる予定なので、SWR関連の記事はもう少し書くかも。
Discussion