◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-04-12中級

Rork でオフライン対応アプリを構築する — AsyncStorage と SQLite を使ったキャッシュ戦略の実践

Rorkで生成したアプリにオフライン機能を実装します。AsyncStorageとSQLiteの使い分け、リスト画面のオフライン対応、通信できないときのUI状態管理、オプティミスティック更新による書き込みまで、ユーザー離脱を防ぐキャッシュ設計を段階的に解説します。

rork59offline-firstasyncstoragesqlitecache-strategy

地下鉄の中でアプリを開いたら真っ白な画面。ローディングスピナーが回り続けて、5秒後に「ネットワークエラー」——ユーザーはアプリを閉じ、二度と戻ってこありません。App Store のレビューには星1つが並ぶ。

Rork で作ったアプリは API から取得したデータを表示するものが多いです。だが、ネットワーク接続が不安定な環境を考慮していないアプリは、どれだけ機能が優れていても実用に耐えありません。オフライン対応は「あると嬉しい機能」ではなく、アプリの生存に関わる基盤設計です。

AsyncStorage と SQLite: 何をどちらに入れるか

React Native(Rork の出力するコードの基盤)でデータを永続化する方法は主に2つあります。AsyncStorage と SQLite です。両者の選択を誤ると、後から移行するのは非常に困難になります。

AsyncStorage を使うべきデータ:

  • ユーザー設定(テーマ、通知のON/OFF、言語設定)
  • 認証トークン
  • 最後に表示した画面のID
  • 10KB以下の小さなJSON

SQLite を使うべきデータ:

  • 記事一覧、商品カタログなどの構造化データ
  • 検索やフィルタリングが必要なデータ
  • 100件以上のリストデータ
  • リレーションを持つデータ

判断基準はシンプルです。「このデータを条件付きで取得したいか?」——YES なら SQLite、NO なら AsyncStorage。AsyncStorage は key-value ストアであり、「カテゴリがXで、日付がY以降のデータ」のような条件検索には向かありません。

Rork で生成されたアプリに AsyncStorage を導入する基本パターン:

import AsyncStorage from '@react-native-async-storage/async-storage';
 
// キャッシュの書き込み(TTL付き)
const cacheData = async (key: string, data: unknown, ttlMinutes: number = 60) => {
  const cacheEntry = {
    data,
    timestamp: Date.now(),
    ttl: ttlMinutes * 60 * 1000,
  };
  await AsyncStorage.setItem(key, JSON.stringify(cacheEntry));
};
 
// キャッシュの読み取り(期限切れチェック付き)
const getCachedData = async <T>(key: string): Promise<T | null> => {
  const raw = await AsyncStorage.getItem(key);
  if (\!raw) return null;
 
  const entry = JSON.parse(raw);
  const isExpired = Date.now() - entry.timestamp > entry.ttl;
 
  if (isExpired) {
    await AsyncStorage.removeItem(key);
    return null;
  }
 
  return entry.data as T;
};

このパターンで注意すべきは TTL(Time To Live)の設定です。短すぎるとオフライン時にデータが消えてしまい、長すぎると古い情報が表示され続ける。私の経験では、ニュースフィード系は30分、ユーザープロフィール系は24時間、マスターデータ系は7日間が良いバランスです。

SQLite でリスト画面のオフライン対応を実現する

100件以上のデータを扱うリスト画面では、SQLite が必要になります。Rork のプロジェクトに expo-sqlite を組み込む手順:

import * as SQLite from 'expo-sqlite';
 
const db = SQLite.openDatabaseSync('app_cache.db');
 
// テーブル初期化(アプリ起動時に1回実行)
const initDatabase = () => {
  db.execSync(`
    CREATE TABLE IF NOT EXISTS articles (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      category TEXT,
      content TEXT,
      cached_at INTEGER,
      is_synced INTEGER DEFAULT 1
    )
  `);
  db.execSync(`
    CREATE INDEX IF NOT EXISTS idx_articles_category 
    ON articles(category)
  `);
};
 
// データの一括キャッシュ(API レスポンス → SQLite)
const cacheArticles = (articles: Article[]) => {
  const now = Date.now();
  const stmt = db.prepareSync(
    'INSERT OR REPLACE INTO articles (id, title, category, content, cached_at) VALUES (?, ?, ?, ?, ?)'
  );
 
  db.execSync('BEGIN TRANSACTION');
  for (const article of articles) {
    stmt.executeSync(article.id, article.title, article.category, article.content, now);
  }
  db.execSync('COMMIT');
};
 
// カテゴリ別取得(オフラインでも高速検索可能)
const getArticlesByCategory = (category: string): Article[] => {
  return db.getAllSync<Article>(
    'SELECT * FROM articles WHERE category = ? ORDER BY cached_at DESC',
    category
  );
};

BEGIN TRANSACTION と COMMIT で一括挿入をラップしているのは重要なポイントです。100件のデータを個別にINSERTすると5秒かかるところが、トランザクションで囲むと0.1秒以下になります。Rork で生成したアプリのパフォーマンスに大きく影響する部分です。

オフライン時の UI 状態管理

データのキャッシュだけでは十分ではありません。ユーザーに「今オフラインだけどデータは見られる」と伝える UI も必要です:

import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState, useCallback } from 'react';
 
const useNetworkAwareData = <T>(
  fetchFn: () => Promise<T>,
  cacheKey: string
) => {
  const [data, setData] = useState<T | null>(null);
  const [source, setSource] = useState<'network' | 'cache' | 'none'>('none');
  const [isRefreshing, setIsRefreshing] = useState(false);
 
  const loadData = useCallback(async () => {
    const netState = await NetInfo.fetch();
 
    if (netState.isConnected) {
      try {
        setIsRefreshing(true);
        const freshData = await fetchFn();
        setData(freshData);
        setSource('network');
        await cacheData(cacheKey, freshData);
      } catch {
        // ネットワーク接続はあるが API が落ちている場合
        const cached = await getCachedData<T>(cacheKey);
        if (cached) {
          setData(cached);
          setSource('cache');
        }
      } finally {
        setIsRefreshing(false);
      }
    } else {
      // オフライン: キャッシュから読み取り
      const cached = await getCachedData<T>(cacheKey);
      if (cached) {
        setData(cached);
        setSource('cache');
      }
    }
  }, [fetchFn, cacheKey]);
 
  useEffect(() => { loadData(); }, [loadData]);
 
  return { data, source, isRefreshing, refresh: loadData };
};

source フィールドを返しているのがポイントです。これを使って UI にインジケーターを表示できる:

const ArticleList = () => {
  const { data, source, isRefreshing, refresh } = useNetworkAwareData(
    fetchArticles,
    'articles_list'
  );
 
  return (
    <View>
      {source === 'cache' && (
        <View style={styles.offlineBanner}>
          <Text>オフラインモード — 最後に取得したデータを表示中</Text>
        </View>
      )}
      <FlatList
        data={data}
        refreshing={isRefreshing}
        onRefresh={refresh}
        renderItem={({ item }) => <ArticleCard article={item} />}
      />
    </View>
  );
};

オプティミスティック更新: オフラインでの書き込み

読み取りだけでなく、書き込み操作(いいね、ブックマーク、コメントなど)もオフラインで対応したい。ここで使うのがオプティミスティック更新パターンです:

const usePendingActions = () => {
  const addPendingAction = async (action: PendingAction) => {
    const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
    pending.push({ ...action, createdAt: Date.now() });
    await cacheData('pending_actions', pending, 7 * 24 * 60); // 7日間保持
  };
 
  const syncPendingActions = async () => {
    const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
    const failed: PendingAction[] = [];
 
    for (const action of pending) {
      try {
        await executeAction(action);
      } catch {
        failed.push(action);
      }
    }
 
    await cacheData('pending_actions', failed, 7 * 24 * 60);
    return { synced: pending.length - failed.length, failed: failed.length };
  };
 
  return { addPendingAction, syncPendingActions };
};

UI側では、ユーザーの操作を即座に反映し、バックグラウンドで同期する:

const handleLike = async (articleId: string) => {
  // 1. UI を即座に更新(楽観的)
  setLiked(true);
  setLikeCount(prev => prev + 1);
 
  // 2. ペンディングアクションに追加
  await addPendingAction({
    type: 'like',
    payload: { articleId },
  });
};

ネットワークが復帰したとき、syncPendingActions を実行すれば溜まった操作がサーバーに送信されます。NetInfo のリスナーでネットワーク復帰を検知して自動同期するのが理想的です。

次のステップ

まずは最もよく使われる画面のデータを AsyncStorage でキャッシュすることから始めてほしい。それだけで「地下鉄で開いたら真っ白」問題は解消します。書き込みのオフライン対応は、読み取りキャッシュが安定してから段階的に導入すれば十分です。

シェア

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

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

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

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-05-20
iOS の 0x8badf00d ウォッチドッグ強制終了に Rork アプリが落ちるときの対処
Rork で作った iOS アプリが起動直後に消える。クラッシュログに 0x8badf00d。Apple のウォッチドッグが React Native の重い初期化を切ったときの正しい直し方を、私の運用中アプリで実際に効いた手順で説明します。
⬡ 開発ツール2026-04-24
Rork アプリのバイナリサイズが 150MB を超えた時に切り分ける 5 つの要因と実践的な削減手順
Rork で生成したアプリが 180MB まで膨らみ、App Store Connect の警告で止まった経験から、原因を 5 要素に分解して 69MB まで落とすまでの手順を、実測値と失敗した箇所も含めて共有します。
⬡ 開発ツール2026-10-15
EXPO_PUBLIC_ の値が、開発では読めて本番だけ undefined になる書き方を、変換結果で見分けます
API の URL が開発中は読めるのに、ビルドした途端に undefined になる。原因は process.env の書き方でした。babel-preset-expo の変換結果を開発・本番で並べ、置き換わる書き方と残ってしまう書き方を表にしました。