概要
この記事では、React NativeのExpo環境でDrizzle ORMを使用してSQLiteデータベースに接続する方法を解説します。Drizzle ORMは、型安全なORMであり、データベース操作が容易で安全に行えるため、React Nativeアプリのデータ管理に最適です。Expoは、React Nativeアプリの開発と配布を効率化するツールで、expo-sqliteモジュールを使用することで、SQLiteデータベースに簡単に接続できます。
Drizzle ORMとExpo SQLiteの概要
DrizzleORM:型安全性が高く、TypeScriptと連携することでエラーを事前に防止できるORMです。SQLのような構文で、JavaScriptやTypeScriptプロジェクトにおけるデータベース操作を効率的に行えます。- Expo:React Nativeアプリ開発用のプラットフォームで、モバイルアプリの開発、ビルド、テスト、配布の一連のプロセスを簡略化します。
- SQLite:軽量なローカルデータベースで、アプリのオフライン機能を実装する際に非常に便利です。特にモバイルアプリでのローカルデータ保存に最適です。
DrizzleORMとExpo、SQLiteの組み合わせにより、React Nativeアプリのデータベース管理が簡単で効率的になります。
ExpoプロジェクトでのDrizzle ORMとSQLiteの接続手順
Expoプロジェクトのセットアップ
まず、Expo CLIがインストールされていない場合は、以下のコマンドでインストールします。
npm install -g expo-cli
次に、新しいExpoプロジェクトを作成します。
npx create-expo-app drizzle-expo-sqlite --template blank-typescript
cd drizzle-expo-sqlite
必要なパッケージのインストール
SQLiteとDrizzle ORMを利用するため、expo-sqliteとdrizzle-ormパッケージをインストールします。
npx expo install expo-sqlite
npm install drizzle-orm
npm install --save-dev drizzle-kit
- expo-sqlite:Expo環境でSQLiteデータベースにアクセスするためのライブラリです。
- drizzle-orm:
DrizzleORMは、型安全なデータベース操作が可能で、データの挿入、取得、更新、削除といった基本的な操作をシンプルに行えます。 - drizzle-kit:スキーマのマイグレーションを管理するための開発ツールです。
Drizzle ORMとSQLiteの接続設定
Drizzle ORMを用いてSQLiteデータベースに接続するために、SQLiteデータベースインスタンスを作成し、Drizzle ORMを初期化します。以下のように、database.tsファイルを作成して接続設定を記述します。
// database.ts
import { drizzle } from 'drizzle-orm/expo-sqlite';
import { openDatabaseSync } from 'expo-sqlite/next';
// SQLiteデータベースに接続(同期API)
const expoDb = openDatabaseSync('app.db');
// Drizzle ORMを初期化
export const db = drizzle(expoDb);
Expo SDK 50以降では、新しい同期API (openDatabaseSync) が推奨されています。この設定で、Drizzle ORMとSQLiteのインスタンスが接続され、dbオブジェクトを通してデータベース操作が可能になります。
レガシーAPI(Expo SDK 49以前)を使用する場合
古いバージョンのExpoを使用している場合は、以下のように非同期APIを使用します。
// database.ts (レガシー)
import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as SQLite from 'expo-sqlite';
// 非同期APIでデータベースを開く
const expoDb = SQLite.openDatabase('app.db');
// Drizzle ORMを初期化
export const db = drizzle(expoDb);
テーブルのスキーマ定義
Drizzle ORMでは、テーブルのスキーマを定義することができます。以下はtasksというタスク管理用のテーブルを作成する例です。
// schema.ts
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// tasksテーブルの定義
export const tasks = sqliteTable('tasks', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
description: text('description'),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
created_at: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
updated_at: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
.$onUpdate(() => new Date())
});
// TypeScript型推論
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
このスキーマ定義では、tasksテーブルに以下のフィールドが含まれます:
id: 自動インクリメントの主キーtitle: タスクの名前(必須)description: タスクの説明(任意)completed: 完了状態を示すブール型(デフォルト: false)created_at: 作成日時(自動設定)updated_at: 更新日時(自動更新)
SQLiteでは論理型が存在しないため、integerにmode: 'boolean'を指定して0/1として保存します。同様に、タイムスタンプはunixepoch()を使用して保存されます。
テーブルの初期化とマイグレーション
スキーマを定義した後、データベースにテーブルを作成する必要があります。アプリ起動時に初期化処理を実行します。
// migrate.ts
import { db } from './database';
import { sql } from 'drizzle-orm';
export async function migrateDb() {
try {
// tasksテーブルが存在しない場合は作成
await db.run(sql`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
completed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
console.log('Database migrated successfully');
} catch (error) {
console.error('Migration failed:', error);
throw error;
}
}
アプリの起動時にこのマイグレーション関数を呼び出すことで、テーブルが確実に存在するようになります。
データ操作 - 挿入、取得、更新、削除
Drizzle ORMを使って、SQLiteデータベース内のデータを管理します。以下に、タスクの挿入、取得、更新、削除の基本操作を解説します。
データの挿入
import { db } from './database';
import { tasks, type NewTask } from './schema';
async function insertTask(newTask: NewTask) {
try {
const result = await db.insert(tasks).values(newTask).returning();
console.log('Task inserted:', result);
return result[0];
} catch (error) {
console.error('Failed to insert task:', error);
throw error;
}
}
// 使用例
await insertTask({
title: '買い物に行く',
description: '牛乳とパンを買う',
completed: false
});
このinsertTask関数は、新しいタスクをtasksテーブルに挿入します。.returning()を使用することで、挿入されたレコードを取得できます。
データの取得
import { eq, and, desc } from 'drizzle-orm';
// すべてのタスクを取得
async function getAllTasks() {
try {
const allTasks = await db.select().from(tasks).orderBy(desc(tasks.created_at));
return allTasks;
} catch (error) {
console.error('Failed to get tasks:', error);
throw error;
}
}
// 未完了タスクのみ取得
async function getIncompleteTasks() {
try {
const incompleteTasks = await db
.select()
.from(tasks)
.where(eq(tasks.completed, false))
.orderBy(desc(tasks.created_at));
return incompleteTasks;
} catch (error) {
console.error('Failed to get incomplete tasks:', error);
throw error;
}
}
// IDでタスクを取得
async function getTaskById(id: number) {
try {
const task = await db.select().from(tasks).where(eq(tasks.id, id));
return task[0];
} catch (error) {
console.error('Failed to get task:', error);
throw error;
}
}
getAllTasks関数はすべてのタスクを新しい順に取得します。getIncompleteTasksは未完了のタスクのみをフィルタリングして取得します。
データの更新
async function updateTask(id: number, completed: boolean) {
try {
const result = await db
.update(tasks)
.set({ completed })
.where(eq(tasks.id, id))
.returning();
console.log('Task updated:', result);
return result[0];
} catch (error) {
console.error('Failed to update task:', error);
throw error;
}
}
// タスクの内容を更新
async function updateTaskContent(id: number, title: string, description?: string) {
try {
const result = await db
.update(tasks)
.set({ title, description })
.where(eq(tasks.id, id))
.returning();
return result[0];
} catch (error) {
console.error('Failed to update task content:', error);
throw error;
}
}
このupdateTask関数は、指定されたidのタスクの完了状態を更新します。updated_atフィールドは$onUpdateにより自動的に更新されます。
データの削除
async function deleteTask(id: number) {
try {
await db.delete(tasks).where(eq(tasks.id, id));
console.log('Task deleted:', id);
} catch (error) {
console.error('Failed to delete task:', error);
throw error;
}
}
// 完了済みタスクをすべて削除
async function deleteCompletedTasks() {
try {
const result = await db.delete(tasks).where(eq(tasks.completed, true));
console.log('Completed tasks deleted');
return result;
} catch (error) {
console.error('Failed to delete completed tasks:', error);
throw error;
}
}
deleteTask関数は、指定されたidのタスクをtasksテーブルから削除します。
React Nativeコンポーネントでのデータ操作
ExpoでReact Nativeコンポーネントを使用し、Drizzle ORMを利用したデータベース操作を実装します。以下は、タスクの完全なCRUD操作を実装した実践的な例です。
// App.tsx
import React, { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
Button,
TextInput,
FlatList,
StyleSheet,
TouchableOpacity,
Alert
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { db } from './database';
import { tasks, type Task } from './schema';
import { migrateDb } from './migrate';
import { eq, desc } from 'drizzle-orm';
export default function App() {
const [taskTitle, setTaskTitle] = useState('');
const [taskDescription, setTaskDescription] = useState('');
const [taskList, setTaskList] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
// 初期化とマイグレーション
useEffect(() => {
initializeDatabase();
}, []);
const initializeDatabase = async () => {
try {
await migrateDb();
await loadTasks();
} catch (error) {
console.error('Database initialization failed:', error);
Alert.alert('エラー', 'データベースの初期化に失敗しました');
} finally {
setIsLoading(false);
}
};
// タスク一覧を読み込み
const loadTasks = useCallback(async () => {
try {
const result = await db.select().from(tasks).orderBy(desc(tasks.created_at));
setTaskList(result);
} catch (error) {
console.error('Failed to load tasks:', error);
Alert.alert('エラー', 'タスクの読み込みに失敗しました');
}
}, []);
// タスクを追加または更新
const saveTask = async () => {
if (!taskTitle.trim()) {
Alert.alert('エラー', 'タスク名を入力してください');
return;
}
try {
if (editingId !== null) {
// 更新
await db
.update(tasks)
.set({ title: taskTitle, description: taskDescription })
.where(eq(tasks.id, editingId));
setEditingId(null);
} else {
// 新規追加
await db.insert(tasks).values({
title: taskTitle,
description: taskDescription,
completed: false
});
}
setTaskTitle('');
setTaskDescription('');
await loadTasks();
} catch (error) {
console.error('Failed to save task:', error);
Alert.alert('エラー', 'タスクの保存に失敗しました');
}
};
// タスクの完了状態を切り替え
const toggleTaskCompletion = async (id: number, currentStatus: boolean) => {
try {
await db
.update(tasks)
.set({ completed: !currentStatus })
.where(eq(tasks.id, id));
await loadTasks();
} catch (error) {
console.error('Failed to toggle task:', error);
Alert.alert('エラー', 'タスクの更新に失敗しました');
}
};
// タスクを編集モードにする
const startEditTask = (task: Task) => {
setEditingId(task.id);
setTaskTitle(task.title);
setTaskDescription(task.description || '');
};
// 編集をキャンセル
const cancelEdit = () => {
setEditingId(null);
setTaskTitle('');
setTaskDescription('');
};
// タスクを削除
const deleteTask = (id: number) => {
Alert.alert(
'削除確認',
'このタスクを削除しますか?',
[
{ text: 'キャンセル', style: 'cancel' },
{
text: '削除',
style: 'destructive',
onPress: async () => {
try {
await db.delete(tasks).where(eq(tasks.id, id));
await loadTasks();
} catch (error) {
console.error('Failed to delete task:', error);
Alert.alert('エラー', 'タスクの削除に失敗しました');
}
}
}
]
);
};
// 完了済みタスクをすべて削除
const clearCompletedTasks = () => {
Alert.alert(
'削除確認',
'完了済みのタスクをすべて削除しますか?',
[
{ text: 'キャンセル', style: 'cancel' },
{
text: '削除',
style: 'destructive',
onPress: async () => {
try {
await db.delete(tasks).where(eq(tasks.completed, true));
await loadTasks();
} catch (error) {
console.error('Failed to delete completed tasks:', error);
Alert.alert('エラー', '完了済みタスクの削除に失敗しました');
}
}
}
]
);
};
if (isLoading) {
return (
<View style={styles.container}>
<Text>データベースを初期化中...</Text>
</View>
);
}
const completedCount = taskList.filter(t => t.completed).length;
const totalCount = taskList.length;
return (
<View style={styles.container}>
<StatusBar style="auto" />
<View style={styles.header}>
<Text style={styles.title}>タスク管理</Text>
<Text style={styles.stats}>
{completedCount} / {totalCount} 完了
</Text>
</View>
<View style={styles.inputContainer}>
<TextInput
placeholder="タスク名"
value={taskTitle}
onChangeText={setTaskTitle}
style={styles.input}
/>
<TextInput
placeholder="説明(任意)"
value={taskDescription}
onChangeText={setTaskDescription}
style={styles.input}
multiline
/>
<View style={styles.buttonRow}>
<Button
title={editingId !== null ? 'タスクを更新' : 'タスクを追加'}
onPress={saveTask}
/>
{editingId !== null && (
<Button title="キャンセル" onPress={cancelEdit} color="#999" />
)}
</View>
</View>
<FlatList
data={taskList}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.taskItem}>
<TouchableOpacity
style={styles.taskContent}
onPress={() => toggleTaskCompletion(item.id, item.completed)}
>
<Text
style={[
styles.taskTitle,
item.completed && styles.taskCompleted
]}
>
{item.title}
</Text>
{item.description && (
<Text style={styles.taskDescription}>{item.description}</Text>
)}
<Text style={styles.taskDate}>
{new Date(item.created_at).toLocaleDateString('ja-JP')}
</Text>
</TouchableOpacity>
<View style={styles.taskActions}>
<TouchableOpacity
onPress={() => startEditTask(item)}
style={styles.actionButton}
>
<Text style={styles.editText}>編集</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => deleteTask(item.id)}
style={styles.actionButton}
>
<Text style={styles.deleteText}>削除</Text>
</TouchableOpacity>
</View>
</View>
)}
ListEmptyComponent={
<Text style={styles.emptyText}>タスクがありません</Text>
}
/>
{completedCount > 0 && (
<Button
title="完了済みタスクをクリア"
onPress={clearCompletedTasks}
color="#ff6b6b"
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
paddingTop: 60,
backgroundColor: '#f5f5f5'
},
header: {
marginBottom: 20
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 5
},
stats: {
fontSize: 16,
color: '#666'
},
inputContainer: {
backgroundColor: 'white',
padding: 15,
borderRadius: 10,
marginBottom: 20
},
input: {
borderBottomWidth: 1,
borderBottomColor: '#ddd',
marginBottom: 10,
padding: 10,
fontSize: 16
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10,
marginTop: 10
},
taskItem: {
backgroundColor: 'white',
padding: 15,
borderRadius: 10,
marginBottom: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
taskContent: {
flex: 1
},
taskTitle: {
fontSize: 18,
fontWeight: '500',
marginBottom: 5
},
taskCompleted: {
textDecorationLine: 'line-through',
color: '#999'
},
taskDescription: {
fontSize: 14,
color: '#666',
marginBottom: 5
},
taskDate: {
fontSize: 12,
color: '#999'
},
taskActions: {
flexDirection: 'row',
gap: 10
},
actionButton: {
padding: 5
},
editText: {
color: '#007AFF',
fontSize: 14
},
deleteText: {
color: '#ff6b6b',
fontSize: 14
},
emptyText: {
textAlign: 'center',
color: '#999',
marginTop: 50,
fontSize: 16
}
});
このアプリケーションは、以下の包括的な機能を実装しています:
- 初期化: アプリ起動時にデータベースマイグレーションを実行
- タスク一覧表示: 新しい順に並べ替えて表示、完了/未完了の統計も表示
- タスク追加: タイトルと説明を入力して新規タスクを作成
- タスク編集: 既存タスクをタップして編集モードに切り替え
- 完了状態の切り替え: タスクをタップして完了/未完了を切り替え
- タスク削除: 個別のタスクを削除(確認ダイアログ付き)
- 一括削除: 完了済みタスクをまとめて削除
- エラーハンドリング: try-catchとAlertでユーザーフレンドリーなエラー表示
- TypeScript型安全性: 全体を通して型チェックを実施
実践例:カスタムフックでデータベース操作を再利用
データベース操作を複数のコンポーネントで再利用できるように、カスタムフックにまとめることができます。
// hooks/useTasks.ts
import { useState, useEffect, useCallback } from 'react';
import { db } from '../database';
import { tasks, type Task, type NewTask } from '../schema';
import { eq, desc } from 'drizzle-orm';
export function useTasks() {
const [taskList, setTaskList] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// タスクを読み込み
const loadTasks = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const result = await db.select().from(tasks).orderBy(desc(tasks.created_at));
setTaskList(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load tasks'));
} finally {
setIsLoading(false);
}
}, []);
// 初回読み込み
useEffect(() => {
loadTasks();
}, [loadTasks]);
// タスクを追加
const addTask = useCallback(async (newTask: NewTask) => {
try {
await db.insert(tasks).values(newTask);
await loadTasks();
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to add task'
};
}
}, [loadTasks]);
// タスクを更新
const updateTask = useCallback(async (
id: number,
updates: Partial<Omit<Task, 'id' | 'created_at' | 'updated_at'>>
) => {
try {
await db.update(tasks).set(updates).where(eq(tasks.id, id));
await loadTasks();
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to update task'
};
}
}, [loadTasks]);
// タスクを削除
const deleteTask = useCallback(async (id: number) => {
try {
await db.delete(tasks).where(eq(tasks.id, id));
await loadTasks();
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to delete task'
};
}
}, [loadTasks]);
// 完了済みタスクを削除
const deleteCompletedTasks = useCallback(async () => {
try {
await db.delete(tasks).where(eq(tasks.completed, true));
await loadTasks();
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to delete completed tasks'
};
}
}, [loadTasks]);
return {
tasks: taskList,
isLoading,
error,
addTask,
updateTask,
deleteTask,
deleteCompletedTasks,
refresh: loadTasks
};
}
このカスタムフックを使用することで、コンポーネントのコードがシンプルになります:
// App.tsx(カスタムフック使用版)
import { useTasks } from './hooks/useTasks';
export default function App() {
const { tasks, isLoading, error, addTask, updateTask, deleteTask } = useTasks();
const handleAddTask = async () => {
const result = await addTask({
title: taskTitle,
description: taskDescription,
completed: false
});
if (result.success) {
setTaskTitle('');
setTaskDescription('');
} else {
Alert.alert('エラー', result.error);
}
};
// ... 残りのコンポーネントロジック
}
エラー対処と注意点
1. データベース接続エラー
問題: expo-sqliteモジュールが見つからない、またはデータベースが開けない
解決策:
npx expo install expo-sqliteで正しくインストールされているか確認- Expo SDK 50以降では
openDatabaseSync、49以前ではopenDatabaseを使用 - iOS/Androidシミュレータではなく実機でテストする場合、アプリを再ビルド
// エラーハンドリング付きの接続例
import { openDatabaseSync } from 'expo-sqlite/next';
try {
const expoDb = openDatabaseSync('app.db');
console.log('Database opened successfully');
} catch (error) {
console.error('Failed to open database:', error);
// フォールバック処理やユーザーへの通知
}
2. 型エラーとスキーマの不一致
問題: TypeScriptの型エラーや、スキーマに合わないデータでの実行時エラー
解決策:
$inferSelectと$inferInsertを使用して型を推論- Zod等のバリデーションライブラリを使用してランタイム検証を追加
import { z } from 'zod';
const newTaskSchema = z.object({
title: z.string().min(1, 'タスク名は必須です').max(200),
description: z.string().optional(),
completed: z.boolean().default(false)
});
async function addTaskWithValidation(data: unknown) {
try {
const validatedData = newTaskSchema.parse(data);
await db.insert(tasks).values(validatedData);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Validation error:', error.errors);
}
throw error;
}
}
3. 非同期操作とレースコンディション
問題: データ更新後に古いデータが表示される、または複数の操作が競合する
解決策:
- 各操作後に
await loadTasks()で最新データを取得 - React 18のuseTransitionやuseDeferredValueを活用
- 楽観的更新(Optimistic Update)を実装
// 楽観的更新の例
const toggleTaskOptimistic = async (id: number, currentStatus: boolean) => {
// UIを即座に更新
setTaskList(prev =>
prev.map(task =>
task.id === id ? { ...task, completed: !currentStatus } : task
)
);
try {
// バックグラウンドでデータベース更新
await db.update(tasks)
.set({ completed: !currentStatus })
.where(eq(tasks.id, id));
} catch (error) {
// エラー時は元に戻す
setTaskList(prev =>
prev.map(task =>
task.id === id ? { ...task, completed: currentStatus } : task
)
);
Alert.alert('エラー', '更新に失敗しました');
}
};
4. SQLiteの制限事項
注意点:
- SQLiteには論理型が存在しないため、
integerで0/1として保存される - タイムスタンプは
unixepoch()を使用してUNIXタイムスタンプとして保存 - 外部キー制約はデフォルトで無効(
PRAGMA foreign_keys = ONで有効化) - トランザクションのネストは制限あり
// 外部キー制約を有効化
await db.run(sql`PRAGMA foreign_keys = ON`);
// トランザクション例
await db.transaction(async (tx) => {
await tx.insert(tasks).values({ title: 'Task 1', completed: false });
await tx.insert(tasks).values({ title: 'Task 2', completed: false });
// すべて成功するか、すべて失敗する
});
まとめ
Drizzle ORMを使うことで、Expo環境のReact Nativeアプリにおいて型安全かつ簡単にSQLiteデータベースを操作できます。expo-sqliteモジュールを使ってローカルデータベースに接続することで、モバイルアプリのオフライン機能やローカルデータ管理が容易になります。
主なポイント:
- 型安全性: TypeScriptとDrizzle ORMの組み合わせにより、コンパイル時に型エラーを検出
- 最新API: Expo SDK 50以降の
openDatabaseSyncを使用した同期API - CRUD操作: 挿入、取得、更新、削除の完全な実装例
- エラーハンドリング: try-catchとユーザーフレンドリーなエラー表示
- 再利用可能なコード: カスタムフックでデータベース操作をカプセル化
- 実践的な例: タスク管理アプリの完全な実装
Drizzle ORMは、React Nativeアプリのデータ管理を効率化し、エラーの少ない堅牢なアプリ開発を実現します。