Reactの複雑な画面をカスタムフックとViewに分離する
Reactで画面を作り込んでいくと、1つのコンポーネントにAPI通信、フォームの状態、画面遷移、表示条件が集まりやすくなります。
この記事では、処理をカスタムフックへまとめ、画面に必要な値と操作をViewModelとして返し、表示をViewコンポーネントへ分離する方法を紹介します。
題材は、メール通知の有効・無効を設定する画面です。
1つのコンポーネントに処理が集まる問題
最初は、次のように1つのコンポーネントへすべて書いても問題ありません。
NotificationSettings.tsxconst NotificationSettings = () => {
const [email, setEmail] = useState('');
const [enabled, setEnabled] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSaved, setIsSaved] = useState(false);
useEffect(() => {
void loadNotificationSettings().then((settings) => {
setEmail(settings.email);
setEnabled(settings.enabled);
});
}, []);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
try {
await saveNotificationSettings({ email, enabled });
setIsSaved(true);
} finally {
setIsSaving(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* 入力項目と表示条件が続く */}
</form>
);
};画面が大きくなると、このコンポーネントは次の責務を同時に持つようになります。
- APIから初期値を取得する
- 入力値を保持する
- 入力内容を検証する
- 保存中や保存完了の状態を管理する
- 状態に応じて表示を切り替える
- HTMLの構造やスタイルを定義する
処理を変更するたびに表示部分も読み解く必要があり、テストではAPI通信とDOM操作の両方を準備することになります。
分割後の構成
今回は次の4ファイルへ分割します。
- NotificationSettings
- index.tsx
- types.ts
- useNotificationSettings.ts
- NotificationSettingsView.tsx
それぞれの責務は次のとおりです。
| ファイル | 責務 |
|---|---|
index.tsx | カスタムフックとViewを接続する |
types.ts | 画面の状態とViewModelの契約を定義する |
useNotificationSettings.ts | API通信、入力値、状態遷移、イベント処理を担当する |
NotificationSettingsView.tsx | ViewModelを受け取り、画面を表示する |
ここでいうViewModelは、特定のライブラリやフレームワークの機能ではありません。Viewが表示と操作に必要とする値をまとめた、通常のTypeScriptのオブジェクトです。
画面の状態を型で表す
最初に、画面の状態とViewModelを定義します。
types.tsimport type { FormEventHandler } from 'react';
export type NotificationSettingsContentState =
| 'loading'
| 'loadError'
| 'form'
| 'saved';
export interface NotificationSettingsViewModel {
contentState: NotificationSettingsContentState;
email: string;
enabled: boolean;
isSaving: boolean;
saveError: string | null;
onEmailChange: (value: string) => void;
onEnabledChange: (value: boolean) => void;
onSubmit: FormEventHandler<HTMLFormElement>;
}
export interface NotificationSettingsViewProps {
viewModel: NotificationSettingsViewModel;
}isLoading、isLoaded、isSavedのような複数の真偽値を持つと、組み合わせによっては「読み込み中かつ保存完了」のような不正な状態を表せてしまいます。
排他的な画面状態は、文字列のUnion型を1つ持つと扱いやすくなります。
状態の決定const contentState: NotificationSettingsContentState = isLoading
? 'loading'
: hasLoadError
? 'loadError'
: isSaved
? 'saved'
: 'form';ただし、すべての真偽値をUnion型へ置き換える必要はありません。isSavingのように、フォームを表示したままボタンだけを無効化する状態は独立した真偽値の方が自然です。
カスタムフックに処理をまとめる
カスタムフックは、画面の入力値と処理を管理し、ViewModelを返します。
useNotificationSettings.tsimport { useEffect, useState } from 'react';
import type {
NotificationSettingsContentState,
NotificationSettingsViewModel,
} from './types';
type NotificationSettings = {
email: string;
enabled: boolean;
};
const loadNotificationSettings = async (): Promise<NotificationSettings> => {
const response = await fetch('/api/notification-settings');
if (!response.ok) {
throw new Error('通知設定を取得できませんでした');
}
return response.json() as Promise<NotificationSettings>;
};
const saveNotificationSettings = async (
settings: NotificationSettings,
): Promise<void> => {
const response = await fetch('/api/notification-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('通知設定を保存できませんでした');
}
};
export const useNotificationSettings = (): NotificationSettingsViewModel => {
const [email, setEmail] = useState('');
const [enabled, setEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [hasLoadError, setHasLoadError] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSaved, setIsSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
const settings = await loadNotificationSettings();
setEmail(settings.email);
setEnabled(settings.enabled);
} catch {
setHasLoadError(true);
} finally {
setIsLoading(false);
}
};
void load();
}, []);
const handleSubmit: NotificationSettingsViewModel['onSubmit'] = async (
event,
) => {
event.preventDefault();
setIsSaving(true);
setIsSaved(false);
setSaveError(null);
try {
await saveNotificationSettings({ email, enabled });
setIsSaved(true);
} catch {
setSaveError('設定を保存できませんでした。もう一度お試しください。');
} finally {
setIsSaving(false);
}
};
const contentState: NotificationSettingsContentState = isLoading
? 'loading'
: hasLoadError
? 'loadError'
: isSaved
? 'saved'
: 'form';
return {
contentState,
email,
enabled,
isSaving,
saveError,
onEmailChange: (value) => {
setEmail(value);
setIsSaved(false);
},
onEnabledChange: (value) => {
setEnabled(value);
setIsSaved(false);
},
onSubmit: handleSubmit,
};
};ViewはsetEmailやsetEnabledを直接受け取らず、onEmailChangeとonEnabledChangeを受け取ります。
この形にすると、「入力を変更したら保存完了表示を消す」といった画面のルールをカスタムフック側に置けます。Viewは、そのルールを知らなくてもイベントを通知できます。
実際のアプリケーションでTanStack Queryなどを使用している場合は、fetchの代わりにQueryやMutationのフックを、このカスタムフックの中から呼び出します。
Viewは表示に必要な処理だけを持つ
Viewは、渡された状態に応じてHTMLを組み立てます。
NotificationSettingsView.tsximport type { NotificationSettingsViewProps } from './types';
export const NotificationSettingsView = ({
viewModel,
}: NotificationSettingsViewProps) => {
if (viewModel.contentState === 'loading') {
return <p>読み込み中です...</p>;
}
if (viewModel.contentState === 'loadError') {
return <p role="alert">通知設定を取得できませんでした。</p>;
}
return (
<section>
<h1>メール通知設定</h1>
{viewModel.contentState === 'saved' && (
<p role="status">設定を保存しました。</p>
)}
{viewModel.saveError && <p role="alert">{viewModel.saveError}</p>}
<form onSubmit={viewModel.onSubmit}>
<label>
通知先メールアドレス
<input
type="email"
required
value={viewModel.email}
disabled={viewModel.isSaving}
onChange={(event) =>
viewModel.onEmailChange(event.currentTarget.value)
}
/>
</label>
<label>
<input
type="checkbox"
checked={viewModel.enabled}
disabled={viewModel.isSaving}
onChange={(event) =>
viewModel.onEnabledChange(event.currentTarget.checked)
}
/>
メール通知を有効にする
</label>
<button type="submit" disabled={viewModel.isSaving}>
{viewModel.isSaving ? '保存中...' : '保存'}
</button>
</form>
</section>
);
};Viewの中には、APIのURL、データ取得のタイミング、保存後に必要な処理がありません。
入力項目の配置や文言を変更するときはViewを確認し、保存時の処理を変更するときはカスタムフックを確認できます。
index.tsxは接続だけを担当する
最後に、カスタムフックが返したViewModelをViewへ渡します。
index.tsximport { NotificationSettingsView } from './NotificationSettingsView';
import { useNotificationSettings } from './useNotificationSettings';
const NotificationSettings = () => {
const viewModel = useNotificationSettings();
return <NotificationSettingsView viewModel={viewModel} />;
};
export default NotificationSettings;このコンポーネントは処理を追加する場所ではなく、カスタムフックとViewの接続点として使用します。
状態が増えたら表示部分も分ける
確認画面や完了画面が加わると、Viewにも多くの条件分岐が生まれます。その場合は、状態ごとの表示を別コンポーネントに分けます。
NotificationSettingsContent.tsxconst NotificationSettingsContent = ({
viewModel,
}: NotificationSettingsViewProps) => {
switch (viewModel.contentState) {
case 'loading':
return <p>読み込み中です...</p>;
case 'loadError':
return <p role="alert">通知設定を取得できませんでした。</p>;
case 'form':
return <NotificationSettingsForm viewModel={viewModel} />;
case 'saved':
return <NotificationSettingsSaved viewModel={viewModel} />;
}
};switchでUnion型を分岐すると、新しい状態を追加したときに修正箇所を見つけやすくなります。
各子コンポーネントには、常にViewModel全体を渡す必要はありません。再利用する小さなコンポーネントには、表示に必要な値とイベントだけを渡します。
Contextを追加する前にpropsで渡す
ViewModelを1段か2段下のコンポーネントへ渡すだけなら、まずpropsを使用します。
画面単位の状態にContextを使用すると、子コンポーネントがどの値に依存しているかが見えにくくなり、単体での再利用やテストも難しくなります。
離れた複数のコンポーネントツリーから同じ状態を参照する場合や、画面をまたいで状態を共有する場合はContextが候補になります。
ViewをAPIなしでテストする
Viewには任意のViewModelを渡せるため、APIをモックせずに表示を確認できます。
NotificationSettingsView.test.tsximport { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { NotificationSettingsView } from './NotificationSettingsView';
import type { NotificationSettingsViewModel } from './types';
const createViewModel = (
values: Partial<NotificationSettingsViewModel> = {},
): NotificationSettingsViewModel => ({
contentState: 'form',
email: 'user@example.com',
enabled: true,
isSaving: false,
saveError: null,
onEmailChange: vi.fn(),
onEnabledChange: vi.fn(),
onSubmit: vi.fn((event) => event.preventDefault()),
...values,
});
test('保存中は保存ボタンを無効にする', () => {
render(
<NotificationSettingsView
viewModel={createViewModel({ isSaving: true })}
/>,
);
expect(screen.getByRole('button', { name: '保存中...' })).toBeDisabled();
});
test('チェック状態の変更をViewModelへ通知する', async () => {
const user = userEvent.setup();
const viewModel = createViewModel();
render(<NotificationSettingsView viewModel={viewModel} />);
await user.click(screen.getByRole('checkbox'));
expect(viewModel.onEnabledChange).toHaveBeenCalledWith(false);
});一方、カスタムフックのテストではAPIをモックし、取得した値がViewModelへ反映されることや、保存後にcontentStateが変わることを確認します。
表示と処理を分けることで、1つのテストですべてを確認する必要がなくなります。
分割しない方がよい場合
この構成は、すべてのコンポーネントに適用するものではありません。
次のようなコンポーネントは、1ファイルのままでも十分です。
- propsを表示するだけである
- ローカルな開閉状態しか持たない
- API通信や画面遷移がない
- 条件分岐が少なく、全体をすぐに把握できる
反対に、API通信、フォーム、ルーティング、複数の表示状態が同じコンポーネントに集まり始めたら、分割を検討します。
ファイル数を増やすこと自体が目的ではありません。変更理由の異なる処理を分け、画面の状態と操作をViewModelの型で明確にすることが目的です。
まとめ
複雑な画面では、カスタムフックが状態と処理を管理し、Viewが表示を担当するように分けると、変更箇所を判断しやすくなります。
分割するときは、次の順番で考えます。
- 排他的な画面状態をUnion型で定義する
- Viewが必要とする値と操作をViewModelとして定義する
- API通信や状態遷移をカスタムフックへ移す
- ViewはViewModelだけを使って表示する
- 小さな画面では無理に分割しない
ViewModelは、画面の都合に合わせた型です。APIのレスポンスをそのまま渡すのではなく、画面が必要とする状態と操作を表すことで、処理と表示の境界を保ちやすくなります。