RORK LABEN
MAX — Rork MaxはClaude CodeとClaude Opus 4.6を基盤に、React NativeではなくネイティブSwiftアプリを直接生成しますAPPLE — Rork MaxはiPhone・iPad・Apple Watch・Apple TV・Vision Pro・iMessageまでAppleエコシステム全体を対象にしますWORKFLOW — 実運用ではAIに足場を作らせ、状態管理とデータ層は自分で書き直す分担に落ち着いてきたという声が目立ちますSEED — 4月にLeft Lane Capital主導で$15Mのシードを調達しました(Peak XV・True Ventures・a16z Speedrunが参加)PAPERLINE — アプリビルダーPaperlineを買収。エンジニア人材の獲得へ今後も買収を続ける方針ですREVIEW — 3ヶ月使ったユーザーの再訪レビューが増え、得意・不得意と向く用途の見極めが進んでいますMAX — Rork MaxはClaude CodeとClaude Opus 4.6を基盤に、React NativeではなくネイティブSwiftアプリを直接生成しますAPPLE — Rork MaxはiPhone・iPad・Apple Watch・Apple TV・Vision Pro・iMessageまでAppleエコシステム全体を対象にしますWORKFLOW — 実運用ではAIに足場を作らせ、状態管理とデータ層は自分で書き直す分担に落ち着いてきたという声が目立ちますSEED — 4月にLeft Lane Capital主導で$15Mのシードを調達しました(Peak XV・True Ventures・a16z Speedrunが参加)PAPERLINE — アプリビルダーPaperlineを買収。エンジニア人材の獲得へ今後も買収を続ける方針ですREVIEW — 3ヶ月使ったユーザーの再訪レビューが増え、得意・不得意と向く用途の見極めが進んでいます
記事一覧/開発ツール
開発ツール/2026-06-18上級

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

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

Rork502Expo139オフライン対応3TanStack Query個人開発183

プレミアム記事

地下鉄のホームで開いたら真っ白なまま固まる——半年ほど前、運用中のアプリにそういう趣旨のレビューが立て続けに付きました。星は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-07-04
「続きを読む」を出すべきか、テキストの実測で決める — Rork(Expo)の折りたたみ表示設計
商品説明やレビュー本文を3行で畳み「続きを読む」を出す実装で、短い文にまでトグルが付いてしまう問題を解決します。onTextLayout で実際の行数を測り、はみ出す時だけトグルを出す設計を、iOS と Android の差、展開アニメーション、文字サイズ拡大への配慮まで動くコードでまとめます。
開発ツール2026-06-30
Rork アプリにホーム画面長押しのクイックアクションを足す — 動的項目とコールド起動の取りこぼしを防ぐ
アイコン長押しで出るクイックアクションを Rork(Expo)アプリに実装する設計です。静的・動的項目の使い分け、iOS と Android の差、そしてコールド起動でアクションがルーター準備前に届いて取りこぼす問題を、保留と再生で防ぐ実装手順までまとめます。
開発ツール2026-06-28
EAS Update を少人数から配り、クラッシュ率で自動的に止める
OTA 更新は一瞬で全員に届くからこそ、悪い更新も一瞬で全員に届きます。EAS Update を少人数(カナリア)から配り、クラッシュ率を見て広げるか止めるかを自動で判断し、最後は端末側で安全網を張る設計を、動くコードでまとめます。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →