Rorkで3日でアプリの原型ができあがったとき、率直に言って感動しました。プロンプトを入力するたびにコンポーネントが生え、スクリーンが組み上がり、「これが動く」という確信がありました。ところが、そのまま2週間後に新機能を追加しようとしたとき、少し詰まりました。コードを開くたびに「どこを触ればいいのか」という感覚が薄れていたのです。
これはRorkへの批判ではありません。AIが生成するコードは「動くことを最優先に最適化されている」という特性があり、それはそれで正しい設計思想です。しかし個人開発でアプリを長く育てていくためには、ある時点で「動くコード」を「育てられるコード」へと変換する作業が必要になります。
累計5,000万ダウンロードのアプリ事業を個人で運営してきた経験から言えば、アプリが長く生き残るかどうかは最初の実装品質よりも「最初のリファクタリングをきちんとやれたか」で決まることが多いです。以下では、Rorkが生成するReact Nativeコードに特有のパターンを具体的に取り上げ、本番品質へ引き上げるための実践的な手法を解説します。
なぜRork生成コードにリファクタリングが必要なのか
Rorkを含むAIアプリビルダーが生成するReact Nativeコードには、開発の初期段階では問題にならないけれど、アプリが成長するにつれて表面化してくる特性があります。実際のコードを見ていくと、次の6つのパターンが繰り返し登場します。
① TypeScript型の省略(any の多用):速度優先のため、型推論が難しい箇所は any や unknown で代替されます。
② スクリーンコンポーネントの肥大化:1つのファイルに200〜400行のJSXが詰め込まれ、UI・ロジック・データ取得が混在します。
③ useState の乱立:5〜10個の useState が独立して並び、相互に依存関係があるにもかかわらず個別に管理されます。
④ useEffect 内の危険な非同期パターン:useEffect にasync関数を直接渡す書き方が頻出し、メモリリークの温床になります。
⑤ 再レンダリングの最適化なし:memo・useCallback・useMemo が使われず、親コンポーネントが更新されるたびに子コンポーネントが再描画されます。
⑥ テストを想定しない設計:API呼び出しがコンポーネント内に直接埋め込まれ、モック化や単体テストが困難な構造になっています。
以下では、それぞれを実際のコード例とともに解説します。公式ドキュメントには載っていないRork特有のパターンに注目してください。
Step 1 — TypeScript型安全化:any との決別
Rorkが生成するAPIレスポンスの取り扱いコードは、次のような形になっていることが多いです。
// ❌ Rorkが生成しがちなパターン
const [userData, setUserData] = useState<any>(null);
const [posts, setPosts] = useState<any[]>([]);
const fetchData = async () => {
const res = await fetch('/api/user');
const data = await res.json(); // data は any
setUserData(data);
setPosts(data.posts); // data.posts も any
};この書き方は動きます。しかし userData.naem とタイポしてもTypeScriptは警告を出しません。3ヶ月後に自分のコードを読み返したとき、data に何が入っているのかを把握するためにAPIドキュメントを開き直す必要が生まれます。
// ✅ リファクタリング後
type User = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
};
type Post = {
id: string;
title: string;
body: string;
createdAt: string;
author: Pick<User, 'id' | 'name'>;
};
type UserWithPosts = User & {
posts: Post[];
};
const [userData, setUserData] = useState<UserWithPosts | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const fetchData = async (): Promise<void> => {
const res = await fetch('/api/user');
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const data: UserWithPosts = await res.json();
setUserData(data);
setPosts(data.posts);
};型を定義するとき、src/types/ フォルダを作成して user.ts・post.ts として分離しておくと、複数のスクリーンから再利用できます。Rorkで生成したプロジェクトには最初このフォルダがありませんが、最初に作るべき場所のひとつです。
実践的な発見:Rorkが生成するコードのうち、res.json() の戻り値を型付けしているケースはほぼありません。ここを as UserWithPosts ではなく satisfies UserWithPosts を使うと、型との整合性をコンパイル時に検証できてより安全です(TypeScript 4.9以降)。
Step 2 — 肥大化したScreenコンポーネントを分割する
Rorkが一度に生成するスクリーンコンポーネントは、250〜400行になることがあります。たとえばユーザープロフィールスクリーンであれば、ヘッダー・アバター・統計情報・投稿一覧・フォローボタンが1つのファイルに詰め込まれます。
// ❌ 肥大化したスクリーンの典型例(抜粋)
export default function ProfileScreen() {
const [user, setUser] = useState<any>(null);
const [posts, setPosts] = useState<any[]>([]);
const [isFollowing, setIsFollowing] = useState(false);
const [followerCount, setFollowerCount] = useState(0);
// ... 多数のuseEffect、ハンドラ関数、スタイル定義 ...
return (
<ScrollView style={styles.container}>
{/* ヘッダー: 約50行のJSX */}
{/* 統計: 約30行のJSX */}
{/* 投稿一覧: FlatListを含む約80行のJSX */}
{/* フォローボタン: 約20行のJSX */}
</ScrollView>
);
}これを分割する際、3つの基準を使っています。①再利用できるか(他のスクリーンでも使えるUI)、②独立したロジックを持つか(それ自身でデータを持つか)、③テストしたいか(独立してテストできる単位か)。
// ✅ 分割後の構造
// src/components/profile/ProfileHeader.tsx
type ProfileHeaderProps = {
user: User;
isFollowing: boolean;
onFollowToggle: () => void;
};
export function ProfileHeader({ user, isFollowing, onFollowToggle }: ProfileHeaderProps) {
return (
<View style={styles.header}>
<Image source={{ uri: user.avatarUrl ?? undefined }} style={styles.avatar} />
<Text style={styles.name}>{user.name}</Text>
<FollowButton isFollowing={isFollowing} onPress={onFollowToggle} />
</View>
);
}
// src/components/profile/ProfileStats.tsx
type ProfileStatsProps = {
postCount: number;
followerCount: number;
followingCount: number;
};
export function ProfileStats({ postCount, followerCount, followingCount }: ProfileStatsProps) {
return (
<View style={styles.statsRow}>
<StatItem label="投稿" value={postCount} />
<StatItem label="フォロワー" value={followerCount} />
<StatItem label="フォロー中" value={followingCount} />
</View>
);
}
// src/screens/ProfileScreen.tsx(スリム化後)
export default function ProfileScreen() {
const { user, posts, isFollowing, stats, handleFollowToggle } = useProfile();
if (!user) return <LoadingScreen />;
return (
<ScrollView style={styles.container}>
<ProfileHeader user={user} isFollowing={isFollowing} onFollowToggle={handleFollowToggle} />
<ProfileStats {...stats} />
<PostList posts={posts} />
</ScrollView>
);
}useProfile というカスタムフックを作ることで、ロジックとUIを完全に分離できます。このフックはスクリーンコンポーネントとは別にテストできるようになります。
Step 3 — useState の乱立を整理する
Rorkが生成するスクリーンには、次のような useState が並んでいることがよくあります。
// ❌ 独立したuseStateが乱立するパターン
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Post[] | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);これらは表面上は独立していますが、実際には密接に連動しています。isLoading が true のとき error は null であるべきです。error がセットされたとき isLoading は false であるべきです。この「暗黙の制約」を5つの useState で管理しようとすると、ある状態更新を忘れたときにバグが生まれます。
// ✅ useReducerで状態を一元管理
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; page: number; hasMore: boolean }
| { status: 'error'; message: string };
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: T; hasMore: boolean }
| { type: 'FETCH_ERROR'; message: string }
| { type: 'LOAD_MORE' };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.data, page: 1, hasMore: action.hasMore };
case 'FETCH_ERROR':
return { status: 'error', message: action.message };
case 'LOAD_MORE':
if (state.status !== 'success') return state;
return { ...state, page: state.page + 1 };
default:
return state;
}
}判別共用型(Discriminated Union)を使うと、status の値によって利用可能なフィールドが型で制限されます。state.status === 'success' のブランチ内では、TypeScriptが state.data の存在を保証してくれます。
小規模なアプリでは useState を整理するだけで十分なこともありますが(状態管理パターンの詳細を参照)、状態が4〜5個を超えて相互依存が発生してきたら useReducer への移行を検討する目安になります。
Step 4 — useEffect 内の非同期処理とメモリリーク対策
Rorkが頻繁に生成するパターンに、useEffect に直接 async 関数を渡す書き方があります。
// ❌ useEffectにasync関数を直接渡すパターン(メモリリークの原因)
useEffect(async () => {
const data = await fetchPosts();
setPosts(data); // コンポーネントがアンマウント後に呼ばれる可能性がある
}, [userId]);useEffect のコールバックは非同期関数を直接受け付けません(TypeScriptは警告を出しませんが、実行時に予期しない動作をします)。さらに深刻な問題は、コンポーネントがアンマウントされた後に setPosts が呼ばれると、Reactが「存在しないコンポーネントへの状態更新」として警告を出すことです。
// ✅ クリーンアップ関数を使った安全な書き方
useEffect(() => {
let cancelled = false; // マウント状態のフラグ
const loadPosts = async () => {
try {
dispatch({ type: 'FETCH_START' });
const data = await fetchPosts(userId);
if (!cancelled) { // アンマウント後は更新しない
dispatch({ type: 'FETCH_SUCCESS', data, hasMore: data.length === PAGE_SIZE });
}
} catch (err) {
if (!cancelled) {
dispatch({ type: 'FETCH_ERROR', message: err instanceof Error ? err.message : '読み込みに失敗しました' });
}
}
};
loadPosts();
return () => {
cancelled = true; // クリーンアップ: アンマウント時にキャンセル
};
}, [userId]);AbortController を使ってfetchリクエスト自体をキャンセルする方法もありますが、上記のフラグ方式は理解しやすく、多くのケースで十分機能します。Rorkで生成したコードを本番に持っていく前に、useEffect 内の非同期処理をすべて確認することをお勧めします。useEffectの無限ループなど関連トラブルも合わせてご確認ください。
Step 5 — 不要な再レンダリングを止める
パフォーマンス最適化は「計測してから最適化する」が原則ですが、Rorkが生成するコードにはよく見られる最適化漏れのパターンがあり、リストアプリや頻繁に更新されるスクリーンで顕著に影響が出ます。
// ❌ 親の更新で毎回再レンダリングされるリストアイテム
function PostList({ posts, onLike }: { posts: Post[]; onLike: (id: string) => void }) {
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<PostItem post={item} onLike={onLike} /> // onLikeが毎回新しい関数参照になる
)}
keyExtractor={(item) => item.id}
/>
);
}// ✅ memo + useCallback を使った最適化
// PostItemを memo でラップ(propsが変わらない限り再レンダリングしない)
const PostItem = React.memo(function PostItem({
post,
onLike,
}: {
post: Post;
onLike: (id: string) => void;
}) {
return (
<View style={styles.item}>
<Text style={styles.title}>{post.title}</Text>
<TouchableOpacity onPress={() => onLike(post.id)}>
<Text>❤️</Text>
</TouchableOpacity>
</View>
);
});
// 親コンポーネントでonLikeをuseCallbackでメモ化
function PostList({ posts, onLike }: { posts: Post[]; onLike: (id: string) => void }) {
const handleLike = useCallback((id: string) => {
onLike(id);
}, [onLike]); // onLikeが変わったときだけ新しい関数を作成
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<PostItem post={item} onLike={handleLike} />
)}
keyExtractor={(item) => item.id}
// FlatListの追加最適化
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
/>
);
}ただし、memo・useCallback・useMemo はすべて「メモ化のコスト」を伴います。単純なコンポーネントや、頻繁に更新されないデータには不要です。React DevTools の Profiler で実際に再レンダリングが問題になっている箇所を確認してから適用するのが確実です。
Step 6 — テスト可能なアーキテクチャへの変換
Rorkが生成するコードでテストが書きにくい最大の理由は、API呼び出しがコンポーネント内に直接埋め込まれていることです。
// ❌ APIがコンポーネント内に直接埋め込まれているパターン
export default function UserListScreen() {
const [users, setUsers] = useState<any[]>([]);
useEffect(() => {
fetch('https://api.example.com/users') // テスト時にモックできない
.then(res => res.json())
.then(data => setUsers(data));
}, []);
// ...
}// ✅ API層を分離してテスト可能にする
// src/api/users.ts
export async function getUsers(): Promise<User[]> {
const res = await fetch('https://api.example.com/users');
if (!res.ok) throw new Error(`Failed to fetch users: ${res.status}`);
return res.json();
}
// src/hooks/useUsers.ts
import { getUsers } from '../api/users';
export function useUsers() {
const [state, dispatch] = useReducer(fetchReducer<User[]>, { status: 'idle' });
const load = useCallback(async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await getUsers(); // 依存注入で差し替え可能
dispatch({ type: 'FETCH_SUCCESS', data, hasMore: false });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', message: err instanceof Error ? err.message : 'エラー' });
}
}, []);
useEffect(() => { load(); }, [load]);
return { state, reload: load };
}
// src/screens/UserListScreen.tsx
export default function UserListScreen() {
const { state } = useUsers();
if (state.status === 'loading') return <ActivityIndicator />;
if (state.status === 'error') return <ErrorView message={state.message} />;
if (state.status !== 'success') return null;
return <FlatList data={state.data} renderItem={...} keyExtractor={...} />;
}// __tests__/useUsers.test.ts — テストが書きやすくなる
import { renderHook, waitFor } from '@testing-library/react-native';
import { useUsers } from '../src/hooks/useUsers';
import * as usersApi from '../src/api/users';
jest.mock('../src/api/users');
test('ユーザー一覧を正常に取得できる', async () => {
const mockUsers: User[] = [
{ id: '1', name: 'テストユーザー', email: 'test@example.com', avatarUrl: null },
];
(usersApi.getUsers as jest.Mock).mockResolvedValue(mockUsers);
const { result } = renderHook(() => useUsers());
await waitFor(() => {
expect(result.current.state.status).toBe('success');
});
if (result.current.state.status === 'success') {
expect(result.current.state.data).toEqual(mockUsers);
}
});API層・カスタムフック・UIコンポーネントの3層に分離することで、それぞれを独立してテストできるようになります。すべての関数にテストを書く必要はありませんが、ビジネスロジックが集まるカスタムフックのテストを書いておくと、リファクタリング時に変更が安全かどうかを確認できる安心感が生まれます。
リファクタリングの優先順位:何から手をつけるか
6つのステップを一度に全部やろうとすると、リファクタリング自体がプロジェクトを止める原因になります。個人開発では特に、「完璧にしたい」という欲求と「早くリリースしたい」という現実のバランスが難しいところです。
私が実際に使っている優先順位の考え方は、ビジネスリスク × コード変更頻度 のマトリックスです。
今すぐ対処する(リリース前必須):
- useEffect内のメモリリーク(Step 4):本番でユーザーが遭遇するクラッシュの原因になります
- APIエラーハンドリングの欠如:ネットワークエラー時にアプリが固まります
1〜2スプリントで対処する(次の機能追加前に):
- TypeScript型安全化(Step 1):新機能追加のたびにバグが混入しやすい箇所
- 肥大化スクリーンの分割(Step 2):変更頻度の高いスクリーンから優先
余裕があれば対処する(品質向上):
- 再レンダリング最適化(Step 5):実際に遅いと感じてから計測して対応
- テスト(Step 6):コア機能から段階的に追加
この優先順位付けは、Rorkで初めてアプリを作る方にも、既存のRorkプロジェクトを見直している方にも、同じように適用できると思います。完全なコードを目指すよりも、「この部分が変わっても壊れないという確信を持てるコード」を少しずつ増やしていくことが、長く育てられるアプリへの道だと感じています。
全体を振り返って:次の一歩
まず useEffect 内の非同期処理を確認することをお勧めします。既存のRorkプロジェクトを開いて、async が useEffect のコールバック内に直接書かれていたら、今日のうちに Step 4 のパターンで書き直してみてください。それだけで本番で遭遇するエラーのかなりの部分を防げます。
その次に TypeScript型を少しずつ充実させていくと、IDE の補完が賢くなり、新機能を追加するスピードが上がります。リファクタリングは一度完了するものではなく、アプリと一緒に少しずつ育てていくものです。
実装の参考になれば幸いです。