背景と前提
Rork を使えば、AIの力を借りてモバイルアプリを素早くプロトタイピングできます。しかし、アプリが成長してスクリーン数が増え、機能が複雑になるにつれて「コードのどこに何があるのかわからない」「一箇所の変更が他に影響する」といった問題に直面する方も少なくありません。
なぜ設計パターンが必要なのか
小規模アプリでは気づかない技術的負債
Rork で生成されたコードは、多くの場合コンポーネント内にビジネスロジック、API呼び出し、状態管理がすべて混在しています。スクリーンが5つ程度の小さなアプリであれば問題になりませんが、20スクリーンを超えるアプリでは以下のような問題が顕在化します。
- コンポーネントが500行を超え、修正箇所の特定が困難になる
- APIの仕様変更で、複数のスクリーンを同時に修正する必要が生じる
- テストを書こうとしても、コンポーネントがAPIに直接依存していて単体テストできない
設計パターンが解決する3つの課題
- 関心の分離: UI 表示、ビジネスロジック、データ取得の責務を明確に分けることで、変更の影響範囲を最小化する
- 再利用性: ロジックをコンポーネントから切り出すことで、異なる画面間でロジックを共有できる
- テスタビリティ: 依存関係を注入可能にすることで、モックを使った単体テストが書きやすくなる
MVVM パターン — カスタムフックで実現する責務分離
MVVM の基本構造
MVVM(Model-View-ViewModel)は、UI層とビジネスロジック層を明確に分離するパターンです。React Native / Expo の文脈では、以下のように対応します。
- Model: データの型定義と永続化ロジック
- View: React コンポーネント(表示のみに責任を持つ)
- ViewModel: カスタムフック(ビジネスロジックと状態管理を担当)
実装例: タスク管理画面
まず Model(型定義)を作成します。
// models/Task.ts
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: Date;
priority: "low" | "medium" | "high";
}
export interface TaskFilter {
status: "all" | "active" | "completed";
priority?: Task["priority"];
}
次に ViewModel(カスタムフック)を実装します。
// viewmodels/useTaskListViewModel.ts
import { useState, useMemo, useCallback } from "react";
import { Task, TaskFilter } from "../models/Task";
export function useTaskListViewModel(initialTasks: Task[]) {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const [filter, setFilter] = useState<TaskFilter>({ status: "all" });
// フィルタリングされたタスク一覧(メモ化)
const filteredTasks = useMemo(() => {
return tasks.filter((task) => {
if (filter.status === "active" && task.completed) return false;
if (filter.status === "completed" && !task.completed) return false;
if (filter.priority && task.priority !== filter.priority) return false;
return true;
});
}, [tasks, filter]);
// タスク完了のトグル
const toggleTask = useCallback((id: string) => {
setTasks((prev) =>
prev.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
)
);
}, []);
// タスクの追加
const addTask = useCallback(
(title: string, priority: Task["priority"] = "medium") => {
const newTask: Task = {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date(),
priority,
};
setTasks((prev) => [newTask, ...prev]);
},
[]
);
// 統計情報
const stats = useMemo(
() => ({
total: tasks.length,
active: tasks.filter((t) => !t.completed).length,
completed: tasks.filter((t) => t.completed).length,
}),
[tasks]
);
return {
filteredTasks,
filter,
setFilter,
toggleTask,
addTask,
stats,
};
}
最後に View(コンポーネント)を作成します。ViewModel が返す値と関数だけを使い、ビジネスロジックは一切含めません。
// screens/TaskListScreen.tsx
import React from "react";
import { View, Text, FlatList, TouchableOpacity } from "react-native";
import { useTaskListViewModel } from "../viewmodels/useTaskListViewModel";
export function TaskListScreen({ initialTasks }) {
const { filteredTasks, toggleTask, stats } =
useTaskListViewModel(initialTasks);
return (
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: "bold" }}>
タスク: {stats.active}件 / {stats.total}件
</Text>
<FlatList
data={filteredTasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => toggleTask(item.id)}>
<Text
style={{
textDecorationLine: item.completed
? "line-through"
: "none",
padding: 12,
}}
>
{item.title}
</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
この構造により、UI の変更は View のみ、ロジックの変更は ViewModel のみで完結し、それぞれを独立してテストできます。
Clean Architecture — 3層設計でアプリを構造化する
Clean Architecture のレイヤー構成
Clean Architecture は、アプリケーションを以下の3層に分割する設計手法です。
- Domain 層: ビジネスルール、エンティティ、ユースケース(外部依存なし)
- Data 層: リポジトリ実装、API クライアント、ローカルストレージ
- Presentation 層: UIコンポーネント、ViewModel、ナビゲーション
重要なルールは「依存の方向は外側から内側へ」です。Domain 層は Data 層や Presentation 層に依存してはいけません。
ディレクトリ構造の設計
src/
├── domain/
│ ├── entities/ # エンティティ(型定義)
│ │ └── Task.ts
│ ├── repositories/ # リポジトリインターフェース
│ │ └── ITaskRepository.ts
│ └── usecases/ # ユースケース
│ ├── GetTasksUseCase.ts
│ └── CreateTaskUseCase.ts
├── data/
│ ├── repositories/ # リポジトリ実装
│ │ └── TaskRepository.ts
│ ├── api/ # API クライアント
│ │ └── taskApi.ts
│ └── local/ # ローカルストレージ
│ └── taskStorage.ts
└── presentation/
├── screens/ # 画面コンポーネント
├── components/ # 共通UIコンポーネント
└── viewmodels/ # ViewModel(カスタムフック)
ユースケースの実装
ユースケースは「アプリケーションの振る舞い」を記述する単位です。1つのユースケースが1つのビジネス操作に対応します。
// domain/usecases/GetTasksUseCase.ts
import { Task, TaskFilter } from "../entities/Task";
import { ITaskRepository } from "../repositories/ITaskRepository";
export class GetTasksUseCase {
constructor(private taskRepository: ITaskRepository) {}
async execute(filter: TaskFilter): Promise<Task[]> {
const tasks = await this.taskRepository.getAll();
return tasks.filter((task) => {
if (filter.status === "active" && task.completed) return false;
if (filter.status === "completed" && !task.completed) return false;
return true;
});
}
}
// domain/repositories/ITaskRepository.ts
import { Task } from "../entities/Task";
export interface ITaskRepository {
getAll(): Promise<Task[]>;
getById(id: string): Promise<Task | null>;
create(task: Omit<Task, "id" | "createdAt">): Promise<Task>;
update(id: string, data: Partial<Task>): Promise<Task>;
delete(id: string): Promise<void>;
}
リポジトリ実装(Data層)
// data/repositories/TaskRepository.ts
import { Task } from "../../domain/entities/Task";
import { ITaskRepository } from "../../domain/repositories/ITaskRepository";
import { taskApi } from "../api/taskApi";
export class TaskRepository implements ITaskRepository {
async getAll(): Promise<Task[]> {
const response = await taskApi.fetchTasks();
return response.map(this.mapToEntity);
}
async getById(id: string): Promise<Task | null> {
const response = await taskApi.fetchTask(id);
return response ? this.mapToEntity(response) : null;
}
async create(
data: Omit<Task, "id" | "createdAt">
): Promise<Task> {
const response = await taskApi.createTask(data);
return this.mapToEntity(response);
}
async update(id: string, data: Partial<Task>): Promise<Task> {
const response = await taskApi.updateTask(id, data);
return this.mapToEntity(response);
}
async delete(id: string): Promise<void> {
await taskApi.deleteTask(id);
}
// APIレスポンスからドメインエンティティへの変換
private mapToEntity(raw: any): Task {
return {
id: raw.id,
title: raw.title,
completed: raw.completed,
createdAt: new Date(raw.created_at),
priority: raw.priority,
};
}
}
依存性注入 — テスタブルなコードを実現する
React Context を使った依存性注入
React のContext API を使って、リポジトリやユースケースの実装を注入可能にします。
// di/ServiceContainer.ts
import React, { createContext, useContext } from "react";
import { ITaskRepository } from "../domain/repositories/ITaskRepository";
import { GetTasksUseCase } from "../domain/usecases/GetTasksUseCase";
import { CreateTaskUseCase } from "../domain/usecases/CreateTaskUseCase";
export interface ServiceContainer {
taskRepository: ITaskRepository;
getTasksUseCase: GetTasksUseCase;
createTaskUseCase: CreateTaskUseCase;
}
const ServiceContext = createContext<ServiceContainer | null>(null);
export function ServiceProvider({
container,
children,
}: {
container: ServiceContainer;
children: React.ReactNode;
}) {
return (
<ServiceContext.Provider value={container}>
{children}
</ServiceContext.Provider>
);
}
export function useService(): ServiceContainer {
const context = useContext(ServiceContext);
if (!context) {
throw new Error(
"useService must be used within a ServiceProvider"
);
}
return context;
}
テスト時のモック注入
依存性注入のおかげで、テスト時にはモックリポジトリを注入できます。
// __tests__/GetTasksUseCase.test.ts
import { GetTasksUseCase } from "../domain/usecases/GetTasksUseCase";
import { ITaskRepository } from "../domain/repositories/ITaskRepository";
// モックリポジトリ
const mockRepository: ITaskRepository = {
getAll: async () => [
{
id: "1",
title: "テストタスク",
completed: false,
createdAt: new Date(),
priority: "medium",
},
{
id: "2",
title: "完了タスク",
completed: true,
createdAt: new Date(),
priority: "high",
},
],
getById: async () => null,
create: async (data) => ({
...data,
id: "new",
createdAt: new Date(),
}),
update: async (id, data) => ({} as any),
delete: async () => {},
};
describe("GetTasksUseCase", () => {
it("should filter active tasks", async () => {
const useCase = new GetTasksUseCase(mockRepository);
const result = await useCase.execute({ status: "active" });
// completed: true のタスクが除外されていることを検証
expect(result).toHaveLength(1);
expect(result[0].title).toBe("テストタスク");
});
});
Rork での実践的なワークフロー
Rork に設計パターンを伝える
Rork でアプリを生成する際、プロンプトに設計方針を明示することで、パターンに沿ったコードを生成させることができます。
タスク管理アプリを作成してください。以下の設計パターンに従ってください:
1. MVVM パターン:
- ビジネスロジックはカスタムフック(viewmodels/)に配置
- コンポーネントは表示のみに責任を持つ
2. ディレクトリ構造:
- src/domain/entities/ — 型定義
- src/domain/usecases/ — ビジネスロジック
- src/data/repositories/ — API通信
- src/presentation/screens/ — 画面
- src/presentation/viewmodels/ — カスタムフック
3. 依存性注入:
- リポジトリはインターフェース経由で利用
- React Context で実装を注入
既存コードのリファクタリング
Rork が生成した既存のコードを段階的にリファクタリングする手順を紹介します。
- まず Model を切り出す: コンポーネント内の型定義を
domain/entities/ に移動
- ViewModel を作成:
useState と関連ロジックをカスタムフックに抽出
- リポジトリを導入: API 呼び出しを
data/repositories/ に集約
- テストを追加: ユースケースとViewModelの単体テストを作成
一度にすべてを変更する必要はありません。1つのスクリーンから始めて、パターンに慣れたら徐々に適用範囲を広げていくのがおすすめです。
個人開発者の視点から(実体験メモ)
ここまでの要点
設計パターンは「大規模アプリのためのもの」と思われがちですが、早い段階から取り入れることで、コードの見通しが良くなり、バグの修正やコードの追加にかかる時間を大幅に短縮できます。
MVVM でUIとロジックを分離し、Clean Architecture でレイヤーの責務を明確にし、依存性注入でテスタビリティを確保する——この3つの柱は、Rork で生成したアプリを「プロダクション品質」に引き上げるための基盤です。
まずは1つの画面で ViewModel(カスタムフック)の分離から始めてみてください。状態管理の高度なパターンやカスタムフックの再利用テクニックと組み合わせ、テストの自動化を導入することで、堅牢なアプリアーキテクチャが完成します。