RORK LABEN
MAX — Rork MaxはiPhone・iPad・Apple Watch・Apple TV・Vision Pro向けにネイティブSwiftを生成し、2クリックでApp Store公開でき、Xcodeを必要としませんSTACK — 通常のRorkはReact Native(Expo)でクロスプラットフォームのモバイルアプリを作る位置づけ。用途に応じた使い分けが鍵ですFOCUS — BoltやLovableのようなWeb中心ツールと違い、RorkはiOS/Androidのネイティブアプリ生成に特化していますBUGS — 実利用レビューでは遭遇したバグの約70%を手動介入なしで解決、残り3割はエクスポート済みコードでの手修正が必要と報告されていますFUNDING — Rorkはa16z(Andreessen Horowitz)から$2.8Mを調達しましたPRICING — 無料で開始でき、有料プランは$25/月からです。まず触ってから判断できますMAX — Rork MaxはiPhone・iPad・Apple Watch・Apple TV・Vision Pro向けにネイティブSwiftを生成し、2クリックでApp Store公開でき、Xcodeを必要としませんSTACK — 通常のRorkはReact Native(Expo)でクロスプラットフォームのモバイルアプリを作る位置づけ。用途に応じた使い分けが鍵ですFOCUS — BoltやLovableのようなWeb中心ツールと違い、RorkはiOS/Androidのネイティブアプリ生成に特化していますBUGS — 実利用レビューでは遭遇したバグの約70%を手動介入なしで解決、残り3割はエクスポート済みコードでの手修正が必要と報告されていますFUNDING — Rorkはa16z(Andreessen Horowitz)から$2.8Mを調達しましたPRICING — 無料で開始でき、有料プランは$25/月からです。まず触ってから判断できます
記事一覧/開発ツール
開発ツール/2026-06-18上級

Rork で出したアプリにオフライン優先を後付けする — 永続キャッシュと書き込みキューの設計

地下鉄でアプリを開いたら真っ白、というレビューが続きました。エラー画面を整えるだけでは足りず、Rork が出した Expo アプリに TanStack Query の永続キャッシュとオフライン書き込みキューを後付けした記録です。楽観的更新・再接続フラッシュ・再生成で消さない隔離まで実装込みで整理します。

Rork417Expo85オフライン対応2TanStack Query個人開発155

プレミアム記事

地下鉄のホームで開いたら真っ白なまま固まる——半年ほど前、運用中のアプリにそういう趣旨のレビューが立て続けに付きました。星は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 の扱いです。教科書どおりだと onErrorctx.prev に巻き戻します。しかしオフライン前提では、巻き戻すと「押した直後に消える」という最悪の体験になります。私は「送信失敗=後で送る」と割り切り、UI は進めたままキューに退避する方針にしました。サーバーが本当のエラー(権限不足など)を返した場合だけ巻き戻すよう、エラー種別で分岐させると、より厳密になります。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
起動直後に前回のデータを描画する persistQueryClient + AsyncStorage の永続キャッシュ実装と、24時間 maxAge の根拠
オフライン中のお気に入り操作を失わない楽観的更新つき Mutation キューと、再接続時に NetInfo で一括フラッシュする全コード
後付けレイヤーを Rork の再生成で消さないための guarded/ 隔離と、本番で踏んだ重複フラッシュ・古いデータ混入の対処
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

開発ツール2026-06-15
電波が切れても操作を止めないアプリ設計 — 楽観的更新と、戻ってきたときの競合解決
Rork で作った Expo アプリを、地下鉄やエレベーターで電波が切れても操作が止まらないように設計します。画面を先に更新する楽観的更新と、通信が戻ったときに食い違いを解く競合解決を、動くコードで組み立てます。
開発ツール2026-06-14
Rork アプリのプッシュ許可、いきなり出して断られていませんか — 事前許諾で取りこぼしを減らす設計
Rork(Expo)で作ったアプリの起動直後に OS のプッシュ許可ダイアログを出すと、断られた瞬間にもう二度と出せなくなります。一度断られると再表示できない iOS の仕様を踏まえ、自前の事前許諾画面で許可率を底上げする設計を、実装コード込みで整理しました。
開発ツール2026-06-14
アプリを開かなくても更新される、を Expo で実際に届ける — Background Task の現実的な設計
Rork が生成する Expo アプリで『毎朝コンテンツが更新される』体験を作ろうとすると、iOS のバックグラウンド実行は思ったほど律儀に動きません。expo-background-task の最小実装と、実行されない前提で破綻しない設計をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →