React Query のキャッシュを localStorage で永続化する
📝 この記事でわかること
-
useQueryのキャッシュはメモリ上にしか残らないから、リロードすると消える -
PersistQueryClientProvider+createAsyncStoragePersisterを使えばlocalStorageにキャッシュを保存できる -
staleTime/gcTime/enabledの組み合わせで、オフラインでも直近のデータを出せる
🌧 背景
ポートフォリオに macOS 風の天気ウィジェットを置いてるんだけど、useEffect + fetch で書いてたせいで色々つらかった。
- リロードするたびに API を叩く(Open-Meteo は無料だけど無駄は無駄)
- オフラインになると
fetchが落ちてウィジェットが空っぽになる - 位置情報も変わってないのに毎回取り直してる
「最後に取れたデータをそのまま見せたいだけ」なのに、自分で書くと localStorage の読み書きやら期限管理やらエラー処理やらで地味に膨らむ。
そこで刺さったのが TanStack Query の Persist Client プラグイン。
✨ PersistQueryClient って何?
TanStack Query のキャッシュ(QueryClient)をまるごと外部ストレージにシリアライズしてくれる公式プラグイン。
-
@tanstack/react-query-persist-client— Provider 本体 -
@tanstack/query-async-storage-persister—localStorage/AsyncStorage向けの persister
便利なのは、アプリ起動時にキャッシュを復元してから子コンポーネントをマウントしてくれるところ。つまり、オフラインでもコンポーネント側は何も考えず useQuery するだけで前回のデータが返ってくる。楽すぎる。
🔧 セットアップ
pnpm add @tanstack/react-query-persist-client @tanstack/query-async-storage-persister
💡
localStorageだけで十分なら、同期版の@tanstack/query-sync-storage-persisterを使うと storage ラッパーを書かずに済んで更にシンプル。今回は将来 IndexedDB など非同期ストレージに差し替える可能性も考えて async 版で揃えてる。
main.tsx の QueryClientProvider を差し替える。
💡
useNetwork()は後で出てくるけど、navigator.onLineとイベントをラップした自作の Context フック。react-useのuseNetworkStateや@uidotdev/usehooksのuseNetworkStateに置き換えても OK。
// Before
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
// After
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // ⚠️ 永続化するなら gcTime は長めに
},
},
});
// 将来 IndexedDB へ差し替える可能性があるので async 版で統一
const persister = createAsyncStoragePersister({
storage: {
getItem: (key) => Promise.resolve(localStorage.getItem(key)),
setItem: (key, value) => {
localStorage.setItem(key, value);
return Promise.resolve();
},
removeItem: (key) => {
localStorage.removeItem(key);
return Promise.resolve();
},
},
});
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
<App />
</PersistQueryClientProvider>
⚠️
gcTimeがデフォのまま(5 分)だと、復元した直後に GC で消える。maxAgeと揃えとくのが安全。
🔁 Before → After(天気ウィジェット)
Before: useEffect + useState
const [weather, setWeather] = useState<WeatherData | null>(null);
useEffect(() => {
if (!coords) return;
apiGet<WeatherData>("https://api.open-meteo.com/v1/forecast", { /* ... */ })
.then(setWeather)
.catch(console.error);
}, [coords]);
- 毎回ネットワークを叩く
- オフラインだと
weatherはnullのまま - キャッシュも期限管理もゼロ
After: useQuery
const { isOnline } = useNetwork();
const { data: weather } = useQuery<WeatherData>({
queryKey: ["weather", coords?.lat, coords?.lon],
queryFn: () =>
apiGet<WeatherData>("https://api.open-meteo.com/v1/forecast", {
params: { /* ... */ },
}),
enabled: !!coords && isOnline, // ⚠️ オフラインの時は投げない
staleTime: 1000 * 60 * 10, // 10 分は fresh 扱い
gcTime: 1000 * 60 * 60 * 24, // 24 時間キャッシュを保持
});
ポイントは 3 つだけ。
| オプション | 役割 | 今回の値 |
|---|---|---|
staleTime |
「fresh」とみなす時間。この間は再フェッチしない | 10 分 |
gcTime |
キャッシュを捨てるまでの時間 | 24 時間 |
enabled |
false の間はクエリを走らせない |
オンライン時だけ |
enabled: isOnline を入れとけば、オフライン中は API を叩かずに persist された前回のキャッシュがそのまま返ってくる。これがやりたかったやつ。
💡 React Query にはそもそも
networkModeというオプションがあって、デフォルトのonlineだとオフライン時にクエリは自動でpaused状態になる。
なのでenabled: isOnlineを省いても近い挙動にはなるんだけど、キャッシュを確実に優先させたい意図を明示できる &isOnlineが変わったタイミングで即リフェッチが走るので、個人的にはこっちが好み。
🎨 オフライン UX もちょい足し
キャッシュが古いかもってのをユーザーに伝えたいから、ネットワーク状態でバナーも出すようにした。
if (!weather && !isOnline) {
return (
<div className="...">
<WifiOff size={32} />
<p>{t("widgets.weather.noData")}</p>
</div>
);
}
return (
<div>
{!isOnline && (
<div className="...">
<WifiOff size={10} />
<span>{t("widgets.weather.staleBanner")}</span>
</div>
)}
{/* 天気の中身 */}
</div>
);
- キャッシュあり + オフライン → 「古いデータかも」バナー付きで表示
- キャッシュなし + オフライン → 空状態 UI

キャッシュあり + オフライン:上部にバナー表示

キャッシュなし + オフライン:空状態 UI
⚠️ ハマりどころ
-
gcTime伸ばし忘れ — persist しても復元直後に消えて「は?」ってなる -
queryKeyに座標を入れる — 位置が変わった時にちゃんとキャッシュを分けたい -
maxAgeとgcTimeを揃える — 短い方で打ち切られる
🎯 結論
-
useEffect + fetchの置き換えなら、まずuseQueryにするだけで体感が変わる - そこに
PersistQueryClientを乗せれば、オフラインファースト UI が 30 行くらいで作れる - 外部 API を叩く小さいウィジェットほど、このパターンの恩恵がでかい
新規でも既存でも QueryClientProvider を差し替えるだけで入るから、試す価値ある。
Discussion