はじめに
SWR を使っていると、今選んでいるプロジェクトのID等をキーに入れて、表示するデータを切り替えることがあると思います。
最近業務で、複数の店舗を切り替えて使う管理画面を作っていて、画面上部で店舗を選ぶと、その店舗の情報や一覧が表示されるようにしていました。
今どの店舗を選んでいるかは、ページを再読み込みしても消えないように sessionStorage に店舗IDとして保存していて、データの取得には SWR を使っていました。
ところが、店舗を切り替えても、画面の表示が前の店舗のまま変わらないことがありました。
調べてみたところ、SWR のキーを sessionStorage の値から作っていたことが関係していて、店舗IDを書き換えても React がそれに気づかないので、キーが前の店舗のまま残っていました。
sessionStorage や localStorage の値をそのまま SWR のキーに使っている方は、同じことが起きているかもしれないので、まとめてみました。
元のコード
店舗ごとにキャッシュを分けるため、SWR のキーに sessionStorage の店舗IDを入れていました。取得処理のほうは、キーとは別に自分で sessionStorage を読んでいます。
const loadShop = async () => {
const shopId = sessionStorage.getItem('currentShopId')
const response = await axios.get('/api/shop', { params: { id: shopId } })
return response.data
}
const useCurrentShop = () => {
const shopId = sessionStorage.getItem('currentShopId')
const { data, mutate } = useSWR(`/api/shop?id=${shopId}`, loadShop)
return { shop: data, mutateShop: mutate }
}
切り替えの処理はこうなっていました。
const pickShop = (nextId: string) => {
sessionStorage.setItem('currentShopId', nextId)
}
コードだけ読むと、店舗IDを書き換えておけば、次の描画で新しい店舗のキーに切り替わりそうに見えます。
sessionStorage を書き換えても再描画は起きない
React は state が変わったときに描画し直すため、sessionStorage.setItem だけではReactのstateは更新されず、このコンポーネントの再描画は発生しません。useCurrentShop のキーは描画のたびに sessionStorage を読んで作っているので、再描画が起きない限り、前の店舗のキーのまま残ってしまいます。
実際の動き
Node のスクリプトで確かめました。ブラウザの代わりに happy-dom を使い、通信には300msかかるようにしています。
import { Window } from 'happy-dom'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import useSWR, { useSWRConfig } from 'swr'
// ブラウザの代わりに happy-dom で window と sessionStorage を用意する
const fakeWindow = new Window()
globalThis.window = fakeWindow
globalThis.document = fakeWindow.document
globalThis.sessionStorage = fakeWindow.sessionStorage
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const startedAt = Date.now()
const log = (msg) => console.log(`${String(Date.now() - startedAt).padStart(4)}ms ${msg}`)
const loadShop = async (key) => {
const shopId = sessionStorage.getItem('currentShopId')
log(`fetch開始 key=${key} sessionStorage=${shopId}`)
await sleep(300)
return { id: shopId }
}
const useCurrentShop = () => {
const shopId = sessionStorage.getItem('currentShopId')
const { data, mutate } = useSWR(`/api/shop?id=${shopId}`, loadShop)
return { shop: data, mutateShop: mutate }
}
const shopState = {}
const ShopPage = () => {
Object.assign(shopState, useCurrentShop(), { cache: useSWRConfig().cache })
return null
}
sessionStorage.setItem('currentShopId', 'A')
createRoot(document.createElement('div')).render(createElement(ShopPage))
await sleep(400)
// 店舗をBに切り替える
sessionStorage.setItem('currentShopId', 'B')
log('切り替え')
await sleep(400)
log(`画面のデータ = ${JSON.stringify(shopState.shop)}`)
// mutate で取り直してみる
await shopState.mutateShop()
log('mutate完了')
log(
`キャッシュA = ${JSON.stringify(shopState.cache.get('/api/shop?id=A')?.data)}`,
)
await sleep(400)
log(`画面のデータ = ${JSON.stringify(shopState.shop)}`)
process.exit(0)
401msで店舗をBに切り替えたあと、400ms待っても画面のデータはAのままで、通信も行われていませんでした。sessionStorage を書き換えただけでは、キーが前の店舗のまま変わっていないことになります。
試しに mutate で取り直してみると、画面はBに切り替わりましたが、803msの通信はキーがAのままBの情報を取りに行っていて、その結果はキャッシュAに入っていました。
これは、useSWR が返す mutate がその hook のキーに紐づいていて、再描画が起きる前に呼ぶと前の店舗のキーを再検証してしまうためです。
キャッシュAの中身が変わったことで再描画が起き、1105msになってやっとキーがBになって、同じ情報をもう一度取りに行っていました。
mutate で直ったように見えたのは、キャッシュが変わったことでたまたま再描画が起きたからで、キーが切り替わるかどうかが、別の理由で再描画が起きるかどうか次第になっていたわけです。
修正方法
店舗IDを、React が変化を知っている値として持つように変えました。sessionStorage への保存はそのまま残したかったので、jotai の atomWithStorage を使っています。値は sessionStorage に保存されつつ、書き換えると atom を読んでいるコンポーネントが再描画されるので、キーも一緒に切り替わります。
const currentShopIdAtom = atomWithStorage<string | null>(
'currentShopId',
null,
createJSONStorage(() => sessionStorage),
{ getOnInit: true },
)
const loadShop = async ([url, shopId]: [string, string]) => {
const response = await axios.get(url, { params: { id: shopId } })
return response.data
}
const useCurrentShop = () => {
const shopId = useAtomValue(currentShopIdAtom)
const { data } = useSWR(shopId ? ['/api/shop', shopId] : null, loadShop)
return { shop: data }
}
getOnInit: true を付けると、最初の描画のときから sessionStorage の値を読むようになります。付けずに試したときは、最初の描画で初期値の null が使われていました。
あわせて、取得処理も sessionStorage を読むのをやめて、キーに入っている店舗IDを使うようにしました。キーと取得処理が別々の場所から店舗IDを読んでいると、今回のようにずれる余地が残ってしまうためです。
切り替えの処理は、IDを書き換えるだけで済むようになりました。mutate を呼ぶ必要もありません。
const setShopId = useSetAtom(currentShopIdAtom)
const pickShop = (nextId: string) => {
setShopId(nextId)
}
先ほどのスクリプトを atom を使う形に変えて実行すると、切り替えた直後にキーがBになり、画面のデータもBの店舗に切り替わっていました。
jotai を使っていない場合でも、useState で持つか、useSyncExternalStore で sessionStorage の変化を React に伝える形にすれば、同じように直せそうです。ただ、ブラウザの storage イベントは値を書き換えたタブ自身では発火しないので、useSyncExternalStore を使うなら、書き込むときに自分で変更を通知してあげる必要がありました。
振り返り
最初は mutate で取り直せば直ると思っていましたが、それはたまたま再描画が起きていただけで、元をたどると React が変化を知らない値からキーを作っていたことが原因でした。キーは描画のたびに作り直されるものなので、描画のきっかけにならない値をキーに入れてしまうと、値とキーがずれてしまいます。
sessionStorage や localStorage、グローバル変数のように React の外にある値をキーに使っている箇所があれば、その値が変わったときに再描画が起きるかどうかを一度確かめてみるのがおすすめです。

