🌧️

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-persisterlocalStorage / 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.tsxQueryClientProvider を差し替える。

💡 useNetwork() は後で出てくるけど、navigator.onLine とイベントをラップした自作の Context フックreact-useuseNetworkState@uidotdev/usehooksuseNetworkState に置き換えても 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]);
  • 毎回ネットワークを叩く
  • オフラインだと weathernull のまま
  • キャッシュも期限管理もゼロ

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

⚠️ ハマりどころ

  1. gcTime 伸ばし忘れ — persist しても復元直後に消えて「は?」ってなる
  2. queryKey に座標を入れる — 位置が変わった時にちゃんとキャッシュを分けたい
  3. maxAgegcTime を揃える — 短い方で打ち切られる

🎯 結論

  • useEffect + fetch の置き換えなら、まず useQuery にするだけで体感が変わる
  • そこに PersistQueryClient を乗せれば、オフラインファースト UI が 30 行くらいで作れる
  • 外部 API を叩く小さいウィジェットほど、このパターンの恩恵がでかい

新規でも既存でも QueryClientProvider を差し替えるだけで入るから、試す価値ある。

🔗 参考

Discussion