一覧画面が一瞬固まった、あの夜のこと
個人開発で運用しているアプリに、お気に入り登録の機能を足したときのことです。数百件までは何の問題もなく動いていたのに、ヘビーユーザーの端末でお気に入りが数千件に達したあたりから、一覧を開くたびに画面が一瞬白くなる、という報告が届きました。
原因は、起動時にお気に入り一覧をまるごと AsyncStorage から読み込み、JSON.parse で巨大な配列を毎回展開していたことでした。保存していたのはただのキーバリューなのに、データが育つほど起動が重くなる。当時の私は「ストレージは後で考えればいい」と高をくくっていて、その油断がそのまま体感速度の劣化として返ってきたわけです。
ローカルストレージの選択は、アプリが小さいうちは何を選んでも違いが出ません。差が出るのは、ユーザーのデータが育ってからです。だからこそ、最初に仕組みを理解して選んでおくと、後からの作り直しを避けられます。
ここでは React Native / Expo ベースの Rork アプリで使える三つの定番 — AsyncStorage、MMKV、SQLite — を、速度の出る理由・出ない理由まで踏み込んで比較します。単なる紹介ではなく、移行手順や落とし穴、そして「遅い SQLite」を「速い SQLite」に変える実装まで、実際につまずいた経験をもとにお伝えします。
AsyncStorage — まず動かすための最短ストア
AsyncStorage は React Native コミュニティが保守するキーバリュー型ストレージです。Rork が生成するプロジェクトでもそのまま使いやすく、学習コストはほぼゼロに近いのが魅力です。
- データ形式: 文字列のキーバリュー(
JSON.stringify でオブジェクトも保存可能)
- API: すべて Promise ベースの非同期
- バックエンド: Android は SQLite(デフォルト6MB上限)、iOS は plist ファイル
- スレッド安全性: 内部でシリアライズされるため並行アクセスでも安全
import AsyncStorage from '@react-native-async-storage/async-storage';
// ユーザー設定を保存する
const saveUserPreferences = async (preferences: {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
}) => {
try {
const jsonValue = JSON.stringify(preferences);
await AsyncStorage.setItem('@user_preferences', jsonValue);
console.log('設定を保存しました');
} catch (error) {
console.error('保存に失敗しました:', error);
}
};
// ユーザー設定を読み込む
const loadUserPreferences = async () => {
try {
const jsonValue = await AsyncStorage.getItem('@user_preferences');
if (jsonValue !== null) {
return JSON.parse(jsonValue);
}
return { theme: 'light', language: 'ja', notifications: true };
} catch (error) {
console.error('読み込みに失敗しました:', error);
return null;
}
};
// 期待される出力:
// saveUserPreferences({ theme: 'dark', language: 'ja', notifications: true })
// => "設定を保存しました"
// loadUserPreferences()
// => { theme: 'dark', language: 'ja', notifications: true }
設定値・フラグ・トークンの一時保存・数十件程度のキャッシュ、そして MVP 段階のアプリには、これで十分です。私自身、新しいアプリの最初の数週間はほぼ AsyncStorage だけで進めます。判断を早く回すフェーズで、ストレージの設計に時間を取られたくないからです。
なぜ AsyncStorage は育つと遅くなるのか
冒頭の障害を理解するには、AsyncStorage が「キーバリュー」をどう保持しているかを知る必要があります。
Android の AsyncStorage は内部的に一つの SQLite テーブルを使い、setItem のたびに該当行を更新します。問題は、巨大な JSON 文字列を一つのキーに詰め込んだときです。一件追加するだけでも、数千件ぶんの配列を JSON.stringify して文字列まるごと書き直し、読むときは再び全体を JSON.parse します。データ件数に比例して、保存も読み込みもコストが増えていきます。
さらに非同期 API しか持たないため、起動直後に値を即座に使いたい場面では、await の解決を待つ間に一瞬のちらつきが生じます。テーマやログイン状態のように「最初のフレームで確定していてほしい値」とは相性が良くありません。
体感としての分かれ目は、私の経験ではおおむね次のあたりです。
- 数十件まで: どのストレージでも差は出ません
- 数百件: AsyncStorage に最初の遅延を感じ始めます
- 数千件以上: 起動・一覧表示で明確に体感できる遅さになります
「件数が増えそうな配列を一つのキーに丸ごと入れている」と気づいたら、それが移行のサインです。
MMKV — 起動直後から使える同期ストレージ
react-native-mmkv は、WeChat(微信)チームが開発した MMKV の React Native バインディングです。メモリマップドファイル(mmap)を使い、AsyncStorage に比べておおよそ 30倍 高速な読み書きを実現します。
- データ形式: キーバリュー型(文字列・数値・真偽値をネイティブ型のまま保存)
- API: 同期・非同期の両対応。
storage.getString() で起動直後から即座に読める
- 暗号化: AES-128 をオプションで有効化
- 複数インスタンス: 用途別にストレージを分離できる
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV({
id: 'app-storage',
// encryptionKey: 'your-encryption-key', // 暗号化する場合
});
// 同期的に保存・取得する
const saveTheme = (theme: 'light' | 'dark') => {
storage.set('user.theme', theme);
console.log(`テーマを ${theme} に設定しました`);
};
const loadTheme = (): 'light' | 'dark' => {
const theme = storage.getString('user.theme');
return (theme as 'light' | 'dark') ?? 'light';
};
// 期待される出力:
// saveTheme('dark') => "テーマを dark に設定しました"
// loadTheme() => "dark"
MMKV の真価は、React の状態と直接つなげられる点にあります。フックを使うと、保存と再レンダリングが一本の線でつながります。
import { useMMKVString, useMMKVBoolean } from 'react-native-mmkv';
function SettingsScreen() {
const [username, setUsername] = useMMKVString('user.name');
const [darkMode, setDarkMode] = useMMKVBoolean('user.darkMode');
return (
<View>
<Text>ユーザー名: {username ?? '未設定'}</Text>
<Switch
value={darkMode ?? false}
onValueChange={(value) => setDarkMode(value)}
/>
</View>
);
// => 値を変更するとMMKVに自動保存され、コンポーネントが再レンダリングされる
}
同期で読めるという一点が、起動時のちらつきを根本から消してくれます。テーマやオンボーディング完了フラグのような「最初のフレームで確定していてほしい値」は、私はほぼ MMKV に寄せています。
AsyncStorage から MMKV へ無停止で移行する
すでに AsyncStorage で運用しているアプリでも、ユーザーのデータを失わずに MMKV へ移せます。鍵は「一度だけ移行を実行し、完了フラグを立てる」ことです。App Store のアップデートで初回起動したときに既存の値を吸い上げ、二回目以降はスキップします。
import AsyncStorage from '@react-native-async-storage/async-storage';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV({ id: 'app-storage' });
const MIGRATION_FLAG = 'migration.asyncToMmkv.v1';
// 移行したいキーの一覧(アプリの実情に合わせて列挙する)
const KEYS_TO_MIGRATE = ['@user_preferences', '@last_tab', '@onboarding_done'];
export const migrateAsyncStorageToMMKV = async () => {
// すでに移行済みなら即座に抜ける(同期チェックなので軽い)
if (storage.getBoolean(MIGRATION_FLAG)) return;
try {
const pairs = await AsyncStorage.multiGet(KEYS_TO_MIGRATE);
for (const [key, value] of pairs) {
if (value !== null) {
storage.set(key, value); // 文字列のまま移す
}
}
storage.set(MIGRATION_FLAG, true);
// 移行が確実に終わってから旧データを掃除する
await AsyncStorage.multiRemove(KEYS_TO_MIGRATE);
console.log(`MMKVへ ${pairs.length} 件を移行しました`);
} catch (error) {
// 失敗時はフラグを立てない。次回起動で再試行される
console.error('移行に失敗しました。次回再試行します:', error);
}
};
// アプリのエントリポイントで一度だけ呼ぶ
// await migrateAsyncStorageToMMKV();
ポイントは三つあります。完了フラグを getBoolean で同期チェックして二回目以降のコストをゼロにすること、移行が成功してから旧データを消すこと、そして失敗時はフラグを立てずに次回へ委ねること。これで、途中でアプリが落ちてもデータが宙ぶらりんになりません。
MMKV で踏みやすい三つの落とし穴
速いからといって万能ではありません。私が実際にやらかした、あるいは危うく踏みかけたものを挙げておきます。
巨大な配列を JSON で詰め込む。MMKV の set 自体は速くても、JSON.stringify / JSON.parse のコストは AsyncStorage と変わりません。数千件の配列を一つのキーに入れる設計は、ストレージを MMKV に替えても根本解決になりません。それは MMKV の仕事ではなく、後述の SQLite の仕事です。
機密データを平文で置く。トークンやパスワードに準ずる情報を扱うなら、encryptionKey を設定するか、より厳密には iOS Keychain / Android Keystore を使います。MMKV の暗号化は便利ですが、鍵そのものをアプリ内にハードコードしては意味がありません。
インスタンスを無計画に増やす。用途別にインスタンスを分けられるのは利点ですが、id を散らかすと「どの値がどこにあるか」が分からなくなります。私は settings / cache の二つ程度に抑え、命名規則(user.theme のようにドット区切り)で整理しています。
SQLite — 構造化データと検索の本命
expo-sqlite は、端末上でリレーショナルデータベースを扱える SQLite のラッパーです。テーブル定義・SQL クエリ・インデックス・トランザクションといった、サーバーサイドに近い機能をローカルで使えます。冒頭の「数千件のお気に入り」のような、件数が育ち検索やソートが要るデータは、本来ここの担当です。
import * as SQLite from 'expo-sqlite';
const db = await SQLite.openDatabaseAsync('myapp.db');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
category TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category);
`);
const addTask = async (title: string, category: string) => {
const result = await db.runAsync(
'INSERT INTO tasks (title, category) VALUES (?, ?)',
[title, category]
);
return result.lastInsertRowId;
};
const getTasksByCategory = async (category: string) => {
return await db.getAllAsync(
'SELECT * FROM tasks WHERE category = ? ORDER BY created_at DESC',
[category]
);
};
// 期待される出力:
// await addTask('買い物リスト作成', '日常') => 1 (lastInsertRowId)
// await getTasksByCategory('日常')
// => [{ id: 1, title: '買い物リスト作成', completed: 0, category: '日常', created_at: '2026-03-29 ...' }]
category にインデックスを張っておくことで、件数が数万件に育っても絞り込みが高速に保たれます。インデックスは「よく WHERE / ORDER BY に使う列」に対して張るのが基本です。
SQLite を「速い DB」にする — WAL とトランザクション一括投入
ここが、この記事で一番お伝えしたい部分です。SQLite が「遅い」と感じるとき、多くは DB のせいではなく、書き込み方のせいです。
SQLite はデフォルトで、一回の INSERT ごとにディスクへの同期(fsync)を行います。サーバーから取得した数百件を一件ずつ await runAsync で入れると、その回数ぶん同期が走り、目に見えて遅くなります。これを 一つのトランザクションでまとめる だけで、同期は実質一回になり、桁違いに速くなります。
import * as SQLite from 'expo-sqlite';
const db = await SQLite.openDatabaseAsync('myapp.db');
// 起動時に一度だけ: WALモードで読み書きの並行性を上げる
await db.execAsync('PRAGMA journal_mode = WAL;');
// 遅い書き方: 1件ずつ(同期がN回走る)
const insertSlow = async (rows: Array<{ title: string; category: string }>) => {
for (const r of rows) {
await db.runAsync('INSERT INTO tasks (title, category) VALUES (?, ?)', [r.title, r.category]);
}
};
// 速い書き方: トランザクションで一括(同期は実質1回)
const insertFast = async (rows: Array<{ title: string; category: string }>) => {
await db.withTransactionAsync(async () => {
const stmt = await db.prepareAsync(
'INSERT INTO tasks (title, category) VALUES (?, ?)'
);
try {
for (const r of rows) {
await stmt.executeAsync([r.title, r.category]);
}
} finally {
await stmt.finalizeAsync();
}
});
};
// 期待される傾向(端末・件数で変動しますが、桁が変わります):
// 1,000件の挿入 insertSlow ≈ 数秒 / insertFast ≈ 0.1秒台
二つの工夫を組み合わせています。PRAGMA journal_mode = WAL で、読み込みと書き込みが互いをブロックしにくくなります(一覧をスクロールしながら裏で同期、といった処理が滑らかになります)。そして withTransactionAsync + prepareAsync で、同じ SQL を準備済みステートメントとして使い回しつつ、まとめてコミットします。
私自身、サーバー同期が「妙に遅い」と感じて調べたら、原因は一件ずつの INSERT だった、という経験が何度かあります。データ構造を疑う前に、まず書き込み方を疑う。これは覚えておく価値があります。
どれを選ぶか — データの性質から即決する
迷ったら、保存したいデータに次の順で問いかけてみてください。
起動の最初のフレームで確定していてほしいか。 はいなら MMKV。テーマ、ログイン状態、オンボーディング完了フラグ、最後に開いていたタブなどが該当します。同期で読めることそのものが価値です。
件数が育ち、検索・ソート・集計が必要か。 はいなら SQLite。お気に入り、タスク、履歴、家計簿の明細など、行が増えていくデータです。インデックスとトランザクションを使えば数万件でも快適です。
どちらでもなく、ごく少量で手早く済ませたいだけか。 それなら AsyncStorage で十分です。プロトタイプや、移行するほどでもない小さなキャッシュに向きます。
現実のアプリでは、これらは排他ではなく併用が基本です。設定は MMKV、業務データは SQLite、という二段構えが、私のいまの標準的な組み方です。
実践 — 設定は MMKV・業務データは SQLite を一つのアプリで
最後に、二段構えを一つのアプリにまとめた初期化コードを示します。MMKV は同期で即座に、SQLite は非同期で確実に立ち上げます。
import { MMKV } from 'react-native-mmkv';
import * as SQLite from 'expo-sqlite';
// MMKV: 設定・UIステート(高速な同期アクセスが必要)
const settingsStorage = new MMKV({ id: 'settings' });
// SQLite: 業務データ(検索・集計が必要)
let db: SQLite.SQLiteDatabase;
export const initializeStorage = async () => {
// MMKVは同期的に即座に使える(起動のちらつきが出ない)
const theme = settingsStorage.getString('theme') ?? 'light';
const lastOpenedTab = settingsStorage.getString('lastTab') ?? 'today';
// SQLiteは非同期で初期化。WALも合わせて有効化する
db = await SQLite.openDatabaseAsync('tasks.db');
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
due_date TEXT,
priority INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
`);
return { theme, lastOpenedTab };
};
// UIステートの保存(同期・即時)
export const saveLastTab = (tab: string) => {
settingsStorage.set('lastTab', tab);
};
// タスクの検索(SQLで柔軟に)
export const searchTasks = async (keyword: string) => {
return await db.getAllAsync(
'SELECT * FROM tasks WHERE title LIKE ? ORDER BY priority DESC',
[`%${keyword}%`]
);
};
// 期待される出力:
// await initializeStorage() => { theme: 'light', lastOpenedTab: 'today' }
// saveLastTab('week') ← 同期的に即座に保存
// await searchTasks('買い物')
// => [{ id: 1, title: '買い物リスト', due_date: '2026-03-30', priority: 1, completed: 0 }]
オフライン同期や競合解決まで踏み込みたい場合は、Rork Max のオフラインファースト設計に、ローカル DB とサーバー間の同期エンジンの考え方をまとめています。トークンの安全な保存についてはRork でのユーザー認証実装も併せてご覧ください。
Rork でアプリを作るときは、この役割分担をそのままプロンプトに落とし込むのが近道です。たとえば「設定(テーマ・言語・通知)は MMKV で同期管理し、タスクは expo-sqlite で WAL を有効にしてカテゴリ検索に対応、起動時に MMKV を即読み込み、SQLite は非同期初期化」と具体的に指定すると、生成されるコードの精度が上がります。
次の一手として、いま開発中のアプリで「件数が増えそうな配列を一つのキーに丸ごと入れていないか」を一度だけ確認してみてください。もし見つかったら、それが SQLite へ移すべきデータです。お読みいただきありがとうございました。