地下鉄のホームで開いたら真っ白なまま固まる——半年ほど前、運用中のアプリにそういう趣旨のレビューが立て続けに付きました。星は2つ。再現しようと電波の弱い場所で試すと、確かにホーム画面が空白のまま、くるくる回り続けます。
私はそれまで、ネットワークが不安定なときのために「読み込み中」と「再試行」のエラー画面は用意していました。けれど、レビューを落とした人たちが求めていたのはエラー画面ではありませんでした。「昨日見た中身を、いまもう一度見せてほしい」だけだったのです。
エラー画面は、失敗を丁寧に伝える仕組みです。一方でオフライン優先は、そもそも失敗を見せない仕組みです。Rork が生成するアプリは初期状態ではサーバー応答を前提に画面を組み立てるため、通信が途切れると描くものがありません。ここに、起動直後から前回のデータを描き、オフライン中の操作も失わない層を後付けする必要がありました。
この記事は、Rork が出した Expo(React Native)プロジェクトに、TanStack Query v5 の永続キャッシュとオフライン書き込みキューを後から組み込んだ実装記録です。個人開発で複数のアプリを運用してきた立場から、特に「後付け」「AI が再生成する前提」という2つの制約の中でどう成立させたかに重点を置きます。
エラー画面を整えるより前に、最後のデータを描く
まず方針です。オフライン対応というと再試行ボタンや通信状態バナーから手を付けがちですが、体感をいちばん大きく変えるのは「起動直後の空白を消すこと」でした。
Rork の生成コードは TanStack Query を使っていれば useQuery でデータを取りに行きます。既定ではキャッシュはメモリ上にしか残らず、アプリを閉じると消えます。次回起動時はまた取得からやり直すため、圏外だと空白になります。
ここで効くのが永続キャッシュです。取得済みのデータをストレージへ書き出しておき、起動時にメモリへ復元する。すると圏外でも「前回の中身」が即座に出ます。レビューの多くは、この一点だけで静まりました。
実装は @tanstack/react-query-persist-client を使います。Expo なら @react-native-async-storage/async-storage を永続先にできます。
// guarded/offline/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// メモリ上のキャッシュ寿命。永続キャッシュとは別物
gcTime: 1000 * 60 * 60 * 24, // 24時間(旧 cacheTime)
staleTime: 1000 * 60 * 5, // 5分は再取得しない
retry: 2,
},
},
});
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "RORK_RQ_CACHE_V1", // スキーマ変更時はサフィックスを上げて捨てる
throttleTime: 1000, // 書き込みを1秒間引く(過剰書き込み防止)
});
export function setupPersistence() {
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24時間より古い永続キャッシュは復元しない
dehydrateOptions: {
// 成功したクエリだけ永続化する(エラー状態を焼き付けない)
shouldDehydrateQuery: (q) => q.state.status === "success",
},
});
}maxAge を24時間にしているのは、壁紙や記事のような「多少古くても困らないが、1日以上前だと体裁が悪い」種類のデータを基準にしたためです。残高や在庫のように鮮度が意味を持つデータでは、ここを数分に絞るか、後述の「古いデータの明示」を併用してください。gcTime はメモリ側の寿命で、永続キャッシュの maxAge とは別の軸です。両方を意識せずに片方だけ延ばすと、メモリから消えた直後に空白が出ます。
アプリ側は PersistQueryClientProvider で包むだけです。Rork の app/_layout.tsx は再生成で上書きされるため、ここでの接続は1行に絞ります(理由は後半で述べます)。
// app/_layout.tsx の最小の接続点
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { queryClient, persister } from "../guarded/offline/queryClient";
export default function RootLayout() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
{/* Rork が生成する既存のツリー */}
</PersistQueryClientProvider>
);
}オフライン中の操作を失わない — 楽観的更新つきの書き込みキュー
読み取りの空白は永続キャッシュで消えました。次は書き込みです。圏外で「お気に入りに追加」を押したとき、何も起きずに無視されると、ユーザーは押したこと自体を忘れます。電波が戻ってから「あれ、保存されてない」となるのが、地味に評価を落とすパターンでした。
ここでの設計判断は、書き込みを「即座に画面へ反映し、送信は後回しにする」ことです。TanStack Query の onMutate で楽観的に UI を更新し、失敗時は onError で巻き戻す。さらにオフライン中は送信自体を保留し、ローカルのキューに積みます。
// guarded/offline/mutationQueue.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
export type PendingMutation = {
id: string; // 重複送信を弾くための一意キー
kind: "favorite.add" | "favorite.remove";
payload: Record<string, unknown>;
createdAt: number;
};
const QUEUE_KEY = "RORK_PENDING_MUTATIONS_V1";
export async function enqueue(m: PendingMutation): Promise<void> {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
const list: PendingMutation[] = raw ? JSON.parse(raw) : [];
// 同一 id が既にあれば積み直さない(二重タップ・再試行対策)
if (list.some((x) => x.id === m.id)) return;
list.push(m);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(list));
}
export async function getQueue(): Promise<PendingMutation[]> {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
}
export async function removeFromQueue(id: string): Promise<void> {
const list = await getQueue();
await AsyncStorage.setItem(
QUEUE_KEY,
JSON.stringify(list.filter((x) => x.id !== id)),
);
}お気に入り追加の useMutation 側はこうなります。送信に失敗しても(圏外でも)キューへ積み、UI は楽観的に進めます。
// guarded/offline/useFavoriteMutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { enqueue } from "./mutationQueue";
import { addFavoriteRequest } from "../../app/api/favorites"; // 生成コード側の送信関数
export function useAddFavorite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) => addFavoriteRequest(itemId),
onMutate: async (itemId) => {
await qc.cancelQueries({ queryKey: ["favorites"] });
const prev = qc.getQueryData<string[]>(["favorites"]) ?? [];
// 画面を即座に更新(楽観的更新)
qc.setQueryData<string[]>(["favorites"], [...prev, itemId]);
return { prev };
},
onError: async (_err, itemId, ctx) => {
// 送信に失敗 = 圏外の可能性。UI は戻さずキューへ退避する
await enqueue({
id: `fav-add-${itemId}`,
kind: "favorite.add",
payload: { itemId },
createdAt: Date.now(),
});
// ※ ここで ctx.prev に巻き戻さないのが肝。巻き戻すと「押したのに消える」体験になる
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["favorites"] });
},
});
}ここで一番悩んだのは onError の扱いです。教科書どおりだと onError で ctx.prev に巻き戻します。しかしオフライン前提では、巻き戻すと「押した直後に消える」という最悪の体験になります。私は「送信失敗=後で送る」と割り切り、UI は進めたままキューに退避する方針にしました。サーバーが本当のエラー(権限不足など)を返した場合だけ巻き戻すよう、エラー種別で分岐させると、より厳密になります。