2023/10/1追記— React NativeでRealmを使いたい場合、Realm React(英語ページ)の利用がおすすめです。この記事は、Realm Reactがリリースされる以前に公開した記事です。
ユーザーが作成するデータをスマホにのみ保存し、サーバー上には保存しないReact Nativeアプリを開発する場合、スマホ内のローカルデータベースにデータを保存することになります。本記事では、ローカルデータベースの1つであるRealmを使ったReact Nativeアプリの開発方法を紹介します。
本記事では、最初にRealmでのデータ操作方法(CRUD :作成、取得、更新、削除)を紹介します。次に、タスク管理アプリの開発を通してReact NativeでのRealmの利用方法を紹介します。最後に、データベーススキーマを変更した際の注意点(マイグレーション)や、GUI上でRealmデータベースを操作できる「Realm Studio」を紹介します。
なぜサーバーではなくスマホに保存するのか
最近はFirebaseのようなmBaaS (Mobile Backend as a Service)が登場したことで、アプリ開発者がインフラを触るハードルが下がってきています。それでもなおサーバーではなくスマホにデータベースを保存するモチベーションとして、以下が挙げられます。
- サーバーの障害によってアプリが使えなくなることがない
- インターネットが使えない場所でもアプリを利用できる
- アプリ開発者がサーバーを維持管理する必要がなくなる
- FirebaseのようなmBaaSでも、ユーザー増に伴い金銭的な負担が生じる
- 大規模なデータ流出は起こらない
- アプリ開発者のもとにユーザーのデータが集まらないため、データ流出の危険性は下がる
使用フレームワーク・ライブラリ
本記事では以下のフレームワーク、ライブラリを利用します。
- React Native
- クロスプラットフォーム対応のスマホアプリを開発できるフレームワーク。
- Realm
- オフラインデータベース。開発元はMongoDB社により2019年買収。MongoDB社が提供するMongoDB Realm(mBaaS)を使うことで、複数端末間でデータベースを同期することもできるが、本記事の対象外。
SQLiteとの比較
スマホ内にデータベースを保存する方法として、RealmのほかにSQLiteがあります。RealmとSQLiteをざっくり比較すると、以下のようになります。
- RealmはSQLiteより軽い
- Realmでは、クエリの結果は遅延読み込みされる
- RealmではSQLを書く必要がない
- ただし、RealmはExpoで使えない(SQLiteはExpoで使える)
本記事の前提
まだReact Native開発環境のセットアップをしていない場合、Setting up the development environment(React Nativeドキュメント)にある“React Native CLI Quickstart”の内容を読み、指示に従ってください。
また、本記事の内容は、以下の環境で動作確認しています。
- macOS Big Sur 11.2.1
- React Native 0.63.4
- Node.js v14.16.0
- Xcode 12.4
- Simulator上のiPhone 11 / iOS 14.4
本記事で作成するアプリ
本記事では、最終的に以下の動画のようなタスク管理アプリを作成します。
Realmの準備
React Nativeアプリの作成
ここでは、MyRealmAppという名前のReact Nativeアプリを作成してみます。なお、ExpoではRealmを使うことができませんので注意してください。
$ npx react-native init MyRealmAppRealmのインストール
$ cd MyRealmApp
$ npm install realm
$ cd ios && pod install && cd ..PCにインストールされているNode.jsのバージョンが古いとエラーになることがあります。
もし、上記のnpm install realmコマンドを実行した結果、The N-API version of this Node instance is (数値). This module supports N-API version(s) (数値). This Node instance cannot run this module.というエラーが表示された場合、Node.jsのバージョンを上げてください。
スキーマの定義とRealmの初期化
本記事では例としてタスク管理アプリを設計してみます。タスクを表現するTaskクラスと、各Taskにはサブタスクを設定できるよう、SubTaskクラスを定義します。
まず、作成したReact Nativeプロジェクト内にsrcフォルダを作成します。srcフォルダは、App.jsと同階層のフォルダに作成してください。次に、srcフォルダ内に以下のファイルを作成します。ファイル名はrealm.jsとします。
./src/realm.js
// Taskの定義
const taskSchema = {
name: 'Task',
primaryKey: '_id',
properties: {
_id: 'objectId', // 'string' や 'int' でも OK
name: 'string',
description: 'string?', // ?をつけると optional
isDone: 'bool',
createdAt: 'date',
subTasks: 'SubTask[]', // クラス名 + '[]' で1対多のリレーションを設定できる
},
};
// SubTaskの定義
const subTaskSchema = {
name: 'SubTask',
primaryKey: '_id',
properties: {
_id: 'objectId',
name: 'string',
isDone: 'bool',
createdAt: 'date',
},
};
// Realmの初期化
export const openRealm = () => {
const config = {
schema: [taskSchema, subTaskSchema],
schemaVersion: 1, // スキーマを変更したらインクリメントする(後述)
};
return new Realm(config);
};
export {BSON} from 'realm';データベースの操作
React NativeでRealmを使う前に、まずRealm単体でデータベースを操作する方法を見ていきます。
このセクションのコードは、作成したReact Nativeプロジェクトのフォルダに入っているApp.js内の適当な場所に書いた上でnpx react-native run-iosコマンドを実行するとSimulator上で動作確認できます。
(npx react-native run-iosコマンドがエラーで失敗する場合、Flipperのバージョンを変更してみてください(Stack Overflow英語版))
オブジェクトの新規作成
import {openRealm, BSON} from './src/realm';
const realm = openRealm();
// 更新系はすべて realm.write(() => { }) (=トランザクション)内に書く
realm.write(() => {
// サブタスクを作成しない場合
realm.create('Task', {
_id: new BSON.ObjectId(),
name: 'タスクの名前',
isDone: false,
createdAt: new Date(),
});
// サブタスクを作成する場合
realm.create('Task', {
_id: new BSON.ObjectId(),
name: 'タスクの名前',
isDone: false,
createdAt: new Date(),
subTasks: [
{
_id: new BSON.ObjectId(),
name: 'サブタスクの名前',
isDone: false,
createdAt: new Date(),
},
],
});
});オブジェクトの取得
filteredで利用可能なクエリについては公式ドキュメントを参照してください。
import {openRealm} from './src/realm';
const realm = openRealm();
// タスクを全部取得
const tasks = realm.objects('Task');
console.log(tasks[0].name); // 「タスクの名前」と表示される
console.log(tasks[1].subTasks[0].name); // 「サブタスクの名前」と表示される
// フィルタの例 — 完了しているタスクのみ取得
const done = tasks.filtered('isDone == true');
console.log(`完了しているタスクは${done.length}件です。`);
// ソートの例 — 名前順で取得
const sorted = tasks.sorted('name');
console.log(sorted[0].name);オブジェクトの更新
JavaScriptのオブジェクトを操作するのと同じ方法でRealm上のデータを更新できます。
import {openRealm} from './src/realm';
const realm = openRealm();
realm.write(() => {
// 更新対象のタスク
const task = realm.objects('Task')[0];
// 更新
task.name = '新しいタスクの名前';
task.isDone = !task.isDone;
});オブジェクトの削除
import {openRealm} from './src/realm';
const realm = openRealm();
realm.write(() => {
// 削除対象のタスク
const task = realm.objects('Task')[0];
if (task) {
// まずサブタスクを削除
realm.delete(task.subTasks);
// 削除
realm.delete(task);
}
});React NativeでRealmを使う
React Nativeアプリ内でTaskを操作できるようにするためのコンテキストとReact Hookを作ります。
以下の例は、GitHubのmongodb-university/realm-tutorial-react-nativeリポジトリ内のコードを参考に、本記事用に書き換えたものです。
まずsrcフォルダ内にprovidersフォルダを作成します。providersフォルダ内にTaskProvider.jsという名前でファイルを作成し、以下のコードを書きます。このコードの詳細については、コード中のコメントを参照してください。
./src/providers/TasksProvider.js
import React, {useContext, useState, useEffect, useRef} from 'react';
import {openRealm, BSON} from '../realm';
const TasksContext = React.createContext(null);
const TasksProvider = ({children}) => {
const [tasks, setTasks] = useState([]);
const realmRef = useRef(null);
useEffect(() => {
realmRef.current = openRealm();
const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
setTasks(tasks);
// Task のデータが更新されたら setTasks する
tasks.addListener(() => {
const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
setTasks(tasks);
});
return () => {
// クリーンアップ
if (realmRef.current) {
realmRef.current.close();
}
};
}, []);
// タスクの新規作成
const createTask = (newTaskName) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
projectRealm.create('Task', {
_id: new BSON.ObjectId(),
name: newTaskName || '新しいタスク',
isDone: false,
createdAt: new Date(),
});
});
};
// タスクの isDone を更新する
const setIsTaskDone = (task, isDone) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
task.isDone = isDone;
});
};
// タスクを削除する
const deleteTask = (task) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
projectRealm.delete(task);
});
};
// useTasks フックで Task を操作できるようにする
return (
<TasksContext.Provider
value={{
createTask,
deleteTask,
setIsTaskDone,
tasks,
}}>
{children}
</TasksContext.Provider>
);
};
// Task を操作するための React Hook
const useTasks = () => {
const task = useContext(TasksContext);
if (task == null) {
throw new Error('useTasks() called outside of a TasksProvider?');
}
return task;
};
export {TasksProvider, useTasks};以上で作成したTasksProviderをApp.jsに追加し、useTasksフックをコンポーネント内で使ってみます。ここでは、srcフォルダ内にcomponentsフォルダを作成し、その中にMain.jsというファイルでコンポーネントを定義します。
./App.js
import React from 'react';
import {TasksProvider} from './src/providers/TasksProvider';
import {Main} from './src/components/Main';
import {StatusBar} from 'react-native';
const App = () => {
return (
<TasksProvider>
<StatusBar barStyle="light-content" />
<Main />
</TasksProvider>
);
};
export default App;./src/components/Main.js (前半)
./src/components/Main.jsには、以下のコードだけではなく、後述する「後半」のコードも入力してください。
import React, {useState, useCallback} from 'react';
import {
SafeAreaView,
FlatList,
View,
Text,
TouchableOpacity,
TextInput,
KeyboardAvoidingView,
StyleSheet,
TouchableWithoutFeedback,
Keyboard,
} from 'react-native';
import {useTasks} from '../providers/TasksProvider';
// コンポーネント間の余白を作るための関数
const spacer = (size) => {
return <View style={{height: size, width: size}} />;
};
export const Main = () => {
const [inputText, setInputText] = useState('');
const {createTask, deleteTask, setIsTaskDone, tasks} = useTasks();
const onSubmitEditing = useCallback(
(event) => {
setInputText(event.nativeEvent.text);
createTask(inputText);
setInputText('');
},
[inputText, setInputText, createTask],
);
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}>
<View style={styles.header}>
<SafeAreaView />
<View style={styles.headerContent}>
{spacer(15)}
<Text accessibilityRole="header" style={styles.headerTitle}>
My Tasks
</Text>
{spacer(15)}
<TextInput
placeholder="Add Task..."
onSubmitEditing={onSubmitEditing}
value={inputText}
onChange={(e) => setInputText(e.nativeEvent.text)}
style={styles.headerInput}
/>
{spacer(24)}
</View>
</View>
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
{tasks.length > 0 ? (
<FlatList
style={styles.tasksContainer}
contentContainerStyle={styles.tasksContentContainer}
data={tasks}
keyExtractor={(item) => item._id.toHexString()}
renderItem={({item}) => (
<View style={styles.taskItem}>
<TouchableOpacity
style={styles.chechboxContainer}
onPress={() => setIsTaskDone(item, !item.isDone)}>
<View
style={[
styles.checkbox,
{
borderColor: item.isDone ? '#2563EB' : '#60A5FA',
backgroundColor: item.isDone ? '#2563EB' : '#fff',
},
]}>
{item.isDone && <Text style={styles.checkboxIcon}>✓</Text>}
</View>
</TouchableOpacity>
<View style={styles.taskContent}>
<Text
style={[
styles.taskName,
{
textDecorationLine: item.isDone
? 'line-through'
: 'none',
color: item.isDone ? '#9CA3AF' : '#111827',
},
]}>
{item.name}
</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteTask(item)}>
<View>
<Text style={styles.deleteButtonText}>Delete</Text>
</View>
</TouchableOpacity>
</View>
)}
/>
) : (
<View style={styles.emptyContent}>
<Text style={styles.emptyMessage}>No Tasks</Text>
</View>
)}
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
};./src/components/Main.js (後半)
スタイル定義です。./src/components/Main.jsには、上記の「前半」のコードと合わせて以下のコードも入力してください。
// スタイル
const styles = StyleSheet.create({
container: {flex: 1},
header: {
backgroundColor: '#2563EB',
},
headerContent: {paddingHorizontal: 24},
headerTitle: {color: 'white', fontSize: 32, fontWeight: 'bold'},
headerInput: {
borderRadius: 6,
height: 40,
paddingHorizontal: 12,
marginHorizontal: -12,
fontSize: 18,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2.5,
},
shadowOpacity: 0.3,
shadowRadius: 2.4,
elevation: 4,
},
tasksContainer: {flex: 1},
tasksContentContainer: {paddingBottom: 20},
taskItem: {
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
},
chechboxContainer: {padding: 20},
checkbox: {
width: 25,
height: 25,
borderRadius: 12,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
checkboxIcon: {color: 'white', fontWeight: 'bold', fontSize: 18},
taskContent: {flex: 1},
taskName: {
fontSize: 20,
fontWeight: '600',
},
deleteButton: {padding: 20},
deleteButtonText: {color: '#EF4444'},
emptyContent: {
justifyContent: 'center',
alignItems: 'center',
flex: 1,
},
emptyMessage: {
fontSize: 20,
fontWeight: '500',
color: '#9CA3AF',
},
});動作確認
ここまでできたら、一度動作確認してみます。今回はSimulator上のiPhoneで動作確認します。以下のコマンドで、シミュレータを立ち上げます。
(以下のコマンドがエラーで失敗する場合、Flipperのバージョンを変更してみてください(Stack Overflow英語版))
$ npx react-native run-ios以下の動画のように動作すれば成功です。もしエラーが発生する場合、「DerivedDataの削除」「iosフォルダ内でpod install再実行」などをお試しください(詳細はエラーメッセージで検索してみてください)。
ここまで書いてきたコードでは、タスクの編集ができません。タスク名を自由に編集できるようにしてみると良いかもしれません(本記事では省略します)。
スキーマ変更時の注意点(schemaVersionとマイグレーション)
アプリストアで配信中のアプリをアップデートする際、データベースのスキーマが変更になる場合、schemaVersionをインクリメントする必要があります。また、必要に応じて、旧スキーマのデータを新スキーマのデータへ移行する処理(マイグレーション)を書く必要があります。
例として、TaskのisDoneを変更してみます。ここまで、タスクの完了状況はisDoneで管理してきました。isDoneはタスクの完了・未完了しか表すことができないため、新しくstatusとして、タスクの状態を以下の3つに分類できるようにしてみます。
- Open :タスク未着手
- InProgress :タスク進行中
- Complete :完了
スキーマを書き換える
./realm.jsに書いたTaskのスキーマ定義を以下のように変更します。この作業はスキーマ定義を直接書き換えます。
// 略
const taskSchema = {
name: "Task",
primaryKey: "_id",
properties: {
_id: "objectId",
name: "string",
description: "string?",
// isDone: 'bool',
status: "string", // isDone を削除し status を追加
createdAt: "date",
subTasks: "SubTask[]",
},
};
マイグレーション処理を書く
./realm.jsファイルを保存し、React Native開発環境を再度実行すると、コンソールに以下のようなエラーが表示されます。
Error: Migration is required due to the following errors:
- Property 'Task.isDone' has been removed.
- Property 'Task.status' has been added.このエラーメッセージを日本語に訳すと以下のようになります。
エラー:以下のエラーのため、マイグレーションが必要です
–プロパティ‘Task.isDone’が削除されました
–プロパティ‘Task.status’が追加されました
このようなエラーが表示された場合、./realm.jsファイルにあるopenRealm関数内のconfig定数を以下のように書き換えます。
// 略
// Realmの初期化
export const openRealm = () => {
const config = {
schema: [taskSchema, subTaskSchema],
// schemaVersion: 1,
schemaVersion: 2, // ① schemaVersion を 1 → 2 へ変更
// ②マイグレーション処理を追加
migration: (oldRealm, newRealm) => {
// 現在保存されているデータの schemaVersion が 2 未満の場合に実行
if (oldRealm.schemaVersion < 2) {
const oldObjects = oldRealm.objects('Task');
const newObjects = newRealm.objects('Task');
// 全TaskデータのisDoneをstatusに変換
for (const objectIndex in oldObjects) {
const oldObject = oldObjects[objectIndex];
const newObject = newObjects[objectIndex];
newObject.status = oldObject.isDone ? 'Complete' : 'Open';
}
}
},
};
return new Realm(config);
};
// 略このファイルを保存し、再度アプリを実行すると、マイグレーションが実行され、データベースが更新されます。まだisDoneがコード上で使われているため、必要に応じてstatusを使うように修正を行ってください(本記事では省略します)。
ここでのポイントは以下の2点です。
① schemaVersionを1 → 2へ変更
Realmにデータベーススキーマが変更になったことを伝えるため、schemaVersionをインクリメントします。schemaVersionは、データベーススキーマに変更がある場合のみ変更します。一度schemaVersionの値を増やした場合、この数値を減らさないでください。
②マイグレーション処理を追加
ユーザーのスマホ内に保存されている古いスキーマのデータベース内のデータを、新しいスキーマのデータベースに変換する処理を書きます。多くの場合、oldRealmのデータをもとに、対応するnewRealm内のデータを全部書き換えるようなコードになると思います。もしくは、optionalではない新しいプロパティが追加された場合、そのプロパティの初期値を設定するコードになると思います。
RealmデータベースGUIで操作できる「Realm Studio」
最後に、Realm Studioを紹介します。Realm StudioはWindows、Linux、macOSに対応したRealmデータベースの管理アプリです。Realm Studioを使うことで、Realmデータベースの中身をGUI上で確認・操作することができます。
Realm Studioは、Realm公式サイト内にあるRealm Studioのページからダウンロードできます。Realm Studioを初めて起動するとメールアドレスの登録画面が表示されるのでメールアドレスを登録します。その後、以下の画面が表示されます。

この画面が表示されたら[Open Realm file]をクリックし、Realmファイルを開きます。Realmファイルのパスは、以下のコードをApp.js等に書いて実行することで確認できます(コンソールにファイルパスが表示されます)。
import {openRealm} from './src/realm.js';
console.log(openRealm().path);無事ファイルを開けると、以下のような画面になります。この画面から、データの確認・編集ができます。

おわりに
React Nativeアプリでオフラインデータを扱うライブラリとしてRealmを紹介してみました。クラウド上にデータを置かないアプリ開発の参考になれば幸いです。
参考文献




