React Nativeの1200行モノリシックコンポーネントを22ファイルに分割した設計と実装
はじめに
React Native(Expo)で開発中のアプリで、アカウント画面のコンポーネントが 1200行超のモノリシックファイル に成長してしまいました。30個以上の useState、12個のサブスクリーン、9個のモーダル、4つの useEffect が1ファイルに集中し、変更のたびに全体を読み解く必要がある状態です。
この記事では、このコンポーネントを 22ファイル に分割したリファクタリングの設計判断と実装テクニックを紹介します。
この記事で得られる知見:
- boolean 乱立 → Union Type で状態を排他制御するパターン
- カスタムフックの責務分割の判断基準
-
React.memoを実際に効かせるためのuseCallback戦略 -
React.lazyによるサブスクリーンの遅延読み込み
リファクタリング前の状況
まず、どれだけ大変だったか見てみましょう。
// AccountScreen.tsx — 1200行超のモノリシックコンポーネント
export const AccountScreen: React.FC<Props> = ({ onClose }) => {
// 30+ の useState...
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showFAQScreen, setShowFAQScreen] = useState(false);
const [showContactUsScreen, setShowContactUsScreen] = useState(false);
const [showMessageScreen, setShowMessageScreen] = useState(false);
const [showDebugMenu, setShowDebugMenu] = useState(false);
const [showSignInModal, setShowSignInModal] = useState(false);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [showTagEditorModal, setShowTagEditorModal] = useState(false);
// ...あと20個以上続く
// 4つの useEffect...
useEffect(() => { /* ユーザーデータ監視 */ }, []);
useEffect(() => { /* LINE連携チェック */ }, []);
useEffect(() => { /* 未読メッセージチェック */ }, []);
useEffect(() => { /* 認証状態チェック */ }, [dep]);
// 12個の if 文でサブスクリーン分岐...
if (showDebugMenu) return <DebugMenuScreen onClose={() => setShowDebugMenu(false)} />;
if (showFAQScreen) return <FAQScreen onClose={() => setShowFAQScreen(false)} />;
if (showContactUsScreen) return <ContactUsScreen onClose={...} />;
// ...あと9個続く
// メイン画面(300行の IIFE パターン)
return (
<SafeAreaView>
{(() => {
// 700行のJSXがここに...
// 9個のモーダルもここに...
})()}
</SafeAreaView>
);
};
問題点:
| 問題 | 影響 |
|---|---|
| 30+ の boolean state | 排他的なはずの画面遷移が同時に true になりうる |
| 1ファイル 1200行 | 変更箇所の特定が困難、レビュー負荷が高い |
| IIFE パターン | JSX 内でローカル変数を宣言するためのワークアラウンド |
| useEffect が4つ混在 | どの副作用がどの状態に影響するか追いにくい |
全体像
リファクタリング後のファイル構成です。
types/
account.ts # Union 型定義
hooks/account/
useAccountUser.ts # ユーザーデータ listener
useAccountAuth.ts # 認証状態 + signOut
useLineLink.ts # LINE連携チェック
useUnreadMessages.ts # 未読メッセージ
useAccountNavigation.ts # 画面遷移管理
components/account/
AccountScreen.tsx # メインコンテナ(hooks 組み立て)
AccountHeader.tsx # ヘッダー
AccountLoadingView.tsx # ローディング
AccountSubScreenRenderer.tsx # サブスクリーンルーティング
AccountModals.tsx # 9個のモーダル統合
styles.ts # 共有スタイル
sections/
LineLinkSection.tsx # LINE連携バナー
MessagesSection.tsx # メッセージ
SubscriptionSection.tsx # サブスクリプション
SettingsSection.tsx # 設定(11項目)
OtherSection.tsx # FAQ / 利用規約等
UserInfoSection.tsx # ユーザー情報
ReferralCodeSection.tsx # 紹介コード
DebugSection.tsx # デバッグ
┌─────────────────────────────────────────────┐
│ AccountScreen.tsx │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ Hooks │ │ Components │ │
│ │ ┌─────────┐ │ │ ┌────────────────┐ │ │
│ │ │ User │ │ │ │ Header │ │ │
│ │ │ Auth │ │ │ │ SubScreen │ │ │
│ │ │ LineLink│ │ │ │ Renderer │ │ │
│ │ │ Unread │ │ │ │ Sections (×8) │ │ │
│ │ │ Nav │ │ │ │ Modals │ │ │
│ │ └─────────┘ │ │ └────────────────┘ │ │
│ └─────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────┘
設計のポイント
ポイント1: boolean 乱立 → Union Type で排他制御
元コードでは12個のサブスクリーンを12個の boolean で管理していました。
// ❌ Before: 排他的なはずの画面を独立した boolean で管理
const [showFAQScreen, setShowFAQScreen] = useState(false);
const [showContactUs, setShowContactUs] = useState(false);
const [showDebugMenu, setShowDebugMenu] = useState(false);
// ...あと9個
これだと 複数の画面が同時に true になりうる という構造的な問題があります。実際にはどのサブスクリーンも排他的(一度に1つしか開かない)なので、Union Type で表現できます。
// ✅ After: Union Type で排他的な状態を型レベルで保証
type AccountSubScreen =
| 'none'
| 'faq'
| 'contactUs'
| 'debugMenu'
| 'messageScreen'
| 'linkLine'
| 'onboarding'
| 'userNameEdit'
| 'accountManagement'
// ...
type AccountModalType =
| 'none'
| 'signIn'
| 'notification'
| 'tagEditor'
| 'speech'
| 'targetLanguage'
// ...
これにより、遷移管理のフックが非常にシンプルになります。
// hooks/account/useAccountNavigation.ts
export function useAccountNavigation() {
const [activeSubScreen, setActiveSubScreen] = useState<AccountSubScreen>('none');
const [activeModal, setActiveModal] = useState<AccountModalType>('none');
const openSubScreen = useCallback((screen: AccountSubScreen) => {
setActiveSubScreen(screen);
}, []);
const closeSubScreen = useCallback(() => {
setActiveSubScreen('none');
}, []);
const openModal = useCallback((modal: AccountModalType) => {
setActiveModal(modal);
}, []);
const closeModal = useCallback(() => {
setActiveModal('none');
}, []);
return {
activeSubScreen, activeModal,
openSubScreen, closeSubScreen,
openModal, closeModal,
};
}
ポイント2: ドメイン別フック分割の判断基準
1ファイルに混在していた4つの useEffect を、データソースの独立性を基準にフックを分割しました。
| Hook | 責務 | データソース |
|---|---|---|
useAccountUser |
ユーザーデータ監視 + プラン判定 | Firestore listener |
useAccountAuth |
認証状態判定 + signOut | Firebase Auth |
useLineLink |
LINE連携チェック | Firestore (accounts) |
useUnreadMessages |
未読メッセージチェック | MessageService |
useAccountNavigation |
画面遷移管理 | ローカル state のみ |
// hooks/account/useAccountUser.ts
export function useAccountUser() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [planStatus, setPlanStatus] = useState('');
useEffect(() => {
const unsubscribe = UserService.listenUser(async (userData) => {
setUser(userData);
setIsLoading(false);
if (userData) {
// Plan status calculation
const isPremium = UserUtils.isPremiumUser(userData);
setPlanStatus(isPremium ? 'Active' : 'Unsubscribed');
}
});
return () => unsubscribe();
}, []);
return { user, isLoading, planStatus };
}
ポイント3: サブスクリーンを switch 文 + React.lazy で整理
12個の if 文の連鎖は、switch 文に置き換えてルーティングコンポーネントに分離しました。
// ❌ Before: if 文の連鎖
if (showDebugMenu) return <DebugMenuScreen onClose={() => setShowDebugMenu(false)} />;
if (showFAQScreen) return <FAQScreen onClose={() => setShowFAQScreen(false)} />;
if (showContactUs) return <ContactUsScreen onClose={() => setShowContactUs(false)} />;
// ...あと9個
// ✅ After: switch 文 + React.lazy
const LazyDebugMenu = React.lazy(() =>
import('@/components/DebugMenuScreen').then(m => ({ default: m.DebugMenuScreen }))
);
const LazyOnboarding = React.lazy(() =>
import('@/components/onboarding/OnboardingFlow').then(m => ({ default: m.OnboardingFlow }))
);
export function SubScreenRenderer({ activeSubScreen, onClose }: Props) {
switch (activeSubScreen) {
case 'debugMenu':
return (
<Suspense fallback={<LoadingView />}>
<LazyDebugMenu onClose={onClose} />
</Suspense>
);
case 'faq':
return <FAQScreen onClose={onClose} />;
case 'contactUs':
return <ContactUsScreen onClose={onClose} />;
// ...
case 'none':
return null;
}
}
実装Tips
Tip 1: React.memo を「本当に」効かせる useCallback 戦略
セクションコンポーネントを React.memo でラップしても、親から 毎レンダーで新しい関数参照 を渡していたら無意味です。
// ❌ React.memo が効かない: 毎レンダーで新しい関数が作られる
<SettingsSection
onOpenTextSize={() => openSubScreen('textSize')}
onOpenWidget={() => openSubScreen('widgetColor')}
/>
// ✅ React.memo が効く: useCallback で参照を安定化
const openTextSize = useCallback(() => openSubScreen('textSize'), [openSubScreen]);
const openWidget = useCallback(() => openSubScreen('widgetColor'), [openSubScreen]);
<SettingsSection
onOpenTextSize={openTextSize}
onOpenWidget={openWidget}
/>
ポイントは、openSubScreen 自体も useCallback で安定化されていること。依存チェーンが全て安定していないと意味がありません。
// useAccountNavigation.ts 内
const openSubScreen = useCallback((screen: AccountSubScreen) => {
setActiveSubScreen(screen); // setState は常に安定した参照
}, []);
Tip 2: 後方互換性を re-export で維持
巨大コンポーネントを分割すると、インポートしている側のファイルを全て修正する必要が出てきます。re-export で回避できます。
// components/AccountScreen.tsx(元のファイル)
// 1200行 → 1行に
export { AccountScreen } from './account/AccountScreen';
これにより、components/index.ts の export * from './AccountScreen' や、他のファイルからのインポートは 一切変更不要 です。
Tip 3: セクションコンポーネントの粒度
セクションの分割粒度は「ScrollView 内の視覚的なまとまり」で判断しました。
// AccountScreen.tsx(メインコンテナ)は組み立てるだけ
<ScrollView>
{showLineLinkBanner && <LineLinkSection onOpen={openLinkLine} />}
<MessagesSection hasUnread={hasUnread} onOpen={openMessages} />
{user && <SubscriptionSection user={user} planStatus={planStatus} />}
<SettingsSection onOpenModal={openModal} />
<OtherSection onOpenFAQ={openFAQ} onOpenContactUs={openContactUs} />
{user?.id && <UserInfoSection user={user} />}
<ReferralCodeSection onOpen={openReferralCode} />
{isDev && <DebugSection onSignOut={handleSignOut} />}
</ScrollView>
各セクションは React.memo でラップし、自身に関係のある props が変わらない限り再レンダーされません。
export const SettingsSection = React.memo(function SettingsSection({
onOpenModal,
onOpenTextSize,
onOpenWidget,
}: Props) {
return (
<View>
<Text>Settings</Text>
<Card>
<MenuItem testID="tags_button" icon="local-offer" label="Tags"
onPress={() => { logAnalytics('tagEditor'); onOpenModal('tagEditor'); }} />
<MenuItem testID="notification_button" icon="notifications" label="Notification"
onPress={() => onOpenModal('notification')} />
{/* ...11項目 */}
</Card>
</View>
);
});
ハマりポイント: IIFE パターンの除去
元コードには、JSX 内でローカル変数を宣言するために IIFE(即時実行関数式)が使われていました。
// ❌ Before: IIFE パターン — 300行が return 内の関数に閉じ込められている
return (
<SafeAreaView>
{(() => {
const currentUser = auth().currentUser;
const userId = currentUser?.uid || 'N/A';
const email = currentUser?.email;
return (
<>
<Header />
<ScrollView>
{/* 700行のJSX... */}
</ScrollView>
{/* 9個のモーダル... */}
</>
);
})()}
</SafeAreaView>
);
// ✅ After: ローカル変数はコンポーネント本体で宣言
const userId: string = currentUser?.uid || t('unavailable');
const userEmail = currentUser?.email ?? undefined;
return (
<SafeAreaView>
<AccountHeader onClose={onClose} />
<ScrollView>
{/* セクションコンポーネントに分割済み */}
</ScrollView>
<AccountModals activeModal={activeModal} onClose={closeModal} />
</SafeAreaView>
);
IIFE が必要だった理由は「return 内で const を使いたかった」だけなので、コンポーネント本体に移動すれば自然に解消します。
まとめ
設計判断の一覧
| 判断 | 選択 | 理由 |
|---|---|---|
| boolean 30個の管理 | Union Type 2つ | 排他状態を型レベルで保証 |
| useEffect の分割基準 | データソース別 | 独立した副作用を独立したフックに |
| サブスクリーン分岐 | switch + React.lazy | 12個の if 連鎖を型安全なルーティングに |
| セクション分割粒度 | 視覚的まとまり | ScrollView 内の各セクションが1コンポーネント |
| 既存インポートとの互換 | re-export | 変更ファイル数を最小化 |
| パフォーマンス最適化 | React.memo + useCallback | 全セクションの不要な再レンダーを防止 |
学び
- 排他的な状態は boolean ではなく Union Type で表現する — 「同時に true になりえない」という制約を型で強制できる
- React.memo は useCallback とセットで初めて効く — 依存チェーン全体が安定していないと意味がない
- re-export は大規模リファクタリングの味方 — インポート元を変えずにファイル構成だけ変えられる
- IIFE パターンは設計の歪みのサイン — JSX 内で変数宣言が必要なら、コンポーネント構成を見直すタイミング
リファクタリングチェックリスト
- 排他的な boolean が3つ以上あれば Union Type に統合する
-
各
useEffectが独立したデータソースを持っているか確認する -
React.memoを使うなら、props の関数が全てuseCallbackで安定しているか確認する - 元のファイルを re-export に変更し、既存インポートが壊れないか確認する
-
全ての
testIDが移動先で保持されているか grep で確認する
Discussion