はじめに
近況共有アプリを作っている中で、最初は App.tsx にほとんどの処理を書いていました。
その方が早く進められるので、MVPの段階ではユーザー(同級生)に提供するのを第一優先にしていました。
ただ、ログイン、投稿編集、下書き保存、アイコン画像、ログイン後の自動投稿、コメントなどが増えるにつれて、1ファイルに置き続けるのが辛くなってきました。
そこで今回は、厳密な クリーンアーキテクチャ ではなく、React アプリで扱いやすい範囲に薄く取り入れる クリーンアーキテクチャLite くらいの温度感で分割しました。
この記事では、何が問題だったか、どの設計にしたか、なぜその分け方にしたかをまとめます。
作るもの
今回やりたかったことは、機能追加しやすいフロントエンドの形にすること次の状態を目指しました。
-
App.tsxはアプリの入口にする - ルーティングは
src/app/routes.tsxに寄せる - ページは
src/presentation/pagesに置く - 共通UIは
src/presentation/componentsに出す - 画面用の状態管理は
src/presentation/hooksに切り出す - 投稿作成や編集の手続きは
src/application/usecasesに寄せる - API、storage、画像変換などの外部依存は
src/infrastructureに置く
完成後の構成は、だいたい次の形です。
src/
app/
routes.tsx
presentation/
pages/
components/
hooks/
application/
usecases/
infrastructure/
api/
avatar/
storage/
shared/
綺麗なフォルダ名を作ることではないので、それっぽい名前にしました。
「画面表示」「業務上の流れ」「外部との接続」を混ぜすぎないことを重視しました。
問題
分割前は、App.tsx に次のようなものが混ざっていました。
- ルーティング
- グループホーム画面
- 投稿作成画面
- 投稿編集画面
- フィード画面
- ログイン画面
- 共通レイアウト
- 投稿カード
- フォームUI
- API呼び出し
- 下書き保存
- 画像プレビュー処理
- ログイン後投稿の復元処理
最初はこの形でも問題になりませんし、むしろ最初は1ファイルに集めた方が手を止めずに開発できます。
ただ、機能が増えると、次のような問題が現れました。
投稿フォームを直したい
-> 新規投稿と編集投稿の両方を見ないといけない
APIのエラー処理を変えたい
-> 画面コンポーネントの中まで読まないと分からない
画像入力を変えたい
-> UI、バリデーション、object URLの破棄が混ざっている
ログイン後投稿の処理を追いたい
-> localStorage、sessionStorage、API、画面遷移が同じ場所にある
問題は、ファイルが長いことだけではなく、変更したい対象の責務が見えにくいことにありました。
設計
採用した方針は、次の3つです。
1. ページは画面の組み立てに集中させる
2. 投稿や編集の手続きは usecase に寄せる
3. fetch や storage などの外部依存を UI から遠ざける
依存の向きは、ざっくり次のようにしました。
presentation
-> application
-> infrastructure
presentation は React コンポーネント、ページ、画面用 hook を持ちます。
application は投稿作成、投稿編集、フィード取得のようなアプリケーション上の手続きを担当します。
infrastructure は API クライアント、localStorage、sessionStorage、画像変換のような外部依存を置く層にしました。
厳密な クリーンアーキテクチャ なら、application から infrastructure へ直接依存させず、port/interface を切ることが多いそうですが、今回はそこまではやりませんでした。
理由は、今の規模で interface を先に増やすと、設計はきれいでも読むファイル数が増えすぎると考えたためです。
今回の目的は、アーキテクチャを完成させることではなく、UIから fetch や localStorage を直接触る箇所を減らし、変更理由ごとにファイルを探しやすくすることを優先したかったからです。
別案
他にも、いくつかの分け方を考えました。
1つ目は、全部を features 配下にまとめる案です。
features/
classmate/
auth/
comment/
Feature-Sliced Design に近い形で、機能ごとに閉じやすいメリットがあります。
ただ、この時点では「投稿作成」「投稿編集」「ログイン後投稿」など、機能というより手続きの複雑さが目立っていました。
そのため、まずは usecases に流れを寄せる方が効きそうだと判断しました。
2つ目は、厳密な クリーンアーキテクチャ に寄せる案です。
application
ports
domain
infrastructure
presentation
この構成は大きなアプリでは強いです。
一方で、今回の規模では、抽象化のためのファイルが増えすぎる可能性がありました。
そこで、まずは Lite にしています。
やらないことを決めたのも、今回の設計判断の一部です。
実装
最初に、App.tsx をアプリの入口だけに近づけました。
現在の src/App.tsx は次の形です。
import { AppRoutes } from "./app/routes";
function App() {
return <AppRoutes />;
}
export default App;
これで、App.tsx を読んでも画面ごとの詳細は出てきません。
ルーティングは src/app/routes.tsx に移しました。
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to={`/g/${defaultSlug}`} replace />} />
<Route path="/g/:slug" element={<GroupHomePage />} />
<Route path="/g/:slug/new" element={<NewClassmatePage />} />
<Route path="/g/:slug/feed" element={<FeedPage />} />
<Route
path="/g/:slug/classmates/:id/edit"
element={<EditClassmatePage />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
この変更で、ルートを追加したいときは routes.tsx を見ればよくなりました。
ページ本体は src/presentation/pages に置いています。
src/presentation/pages/
EditClassmatePage.tsx
FeedPage.tsx
GroupHomePage.tsx
LoginPage.tsx
NewClassmatePage.tsx
NotFoundPage.tsx
PostLoginSubmitPage.tsx
共通UIは src/presentation/components に出しました。
src/presentation/components/
ActionButton.tsx
BackButton.tsx
BottomNav.tsx
ClassmateCard.tsx
ClassmateCommentsPanel.tsx
ClassmateForm.tsx
ClassmateFormFields.tsx
ScreenShell.tsx
StateViews.tsx
たとえば、画面全体の枠は ScreenShell に寄せています。
<ScreenShell
title="近況を入力する"
left={<BackButton />}
footer={<BottomNav slug={slug} active="new" />}
>
<ClassmateForm ... />
</ScreenShell>
ページ側は、「この画面に何を置くか」を中心に読めるようになりました。
フォームとhook
新規投稿と編集投稿では、フォームUIがかなり重複していました。
項目は、名前、現在地、仕事、近況コメント、SNS URL、アイコン画像、公開範囲です。
この重複は ClassmateForm と ClassmateFormFields に寄せました。
<ClassmateForm
avatarError={avatarError}
avatarInputRef={avatarInputRef}
avatarPreviewUrl={avatarPreviewUrl}
commentLength={commentLength}
errors={errors}
isSubmitting={isSubmitting}
onAvatarChange={handleAvatarChange}
onChange={handleDraftChange}
onSubmit={handleFormSubmit}
onClearAvatar={clearAvatarInput}
register={register}
submitError={submitError}
submitIcon="send"
submitLabel="近況を投稿する"
/>
フォーム全体の枠は ClassmateForm に残し、細かい入力欄は ClassmateFormFields に分けています。
アイコン画像の状態管理は useAvatarInput にしました。
const {
avatarError,
avatarInputRef,
avatarPreviewUrl,
clearAvatarInput,
handleAvatarChange,
selectedAvatar,
} = useAvatarInput({ clearPreviewOnInvalid: true });
この hook には、次の責務を持たせています。
- JPG/PNG だけ許可する
- 5MB を超える画像を弾く
-
URL.createObjectURL()でプレビューを作る - 不要になった object URL を
URL.revokeObjectURL()で破棄する - 画像削除状態を管理する
ここはUIコンポーネントに残しても動きます。
ただ、画像入力は新規投稿と編集投稿の両方で使うため、hook にした方が再利用しやすくなります。
usecaseへ寄せた処理
今回一番効果が大きかったのは、投稿作成や編集の手続きを application/usecases に寄せたことです。
現在は、次のようなファイルがあります。
src/application/usecases/
createClassmatePost.ts
updateClassmatePost.ts
submitPendingClassmatePost.ts
getClassmateFeed.ts
getGroupHome.ts
createClassmateComment.ts
toggleClassmateLike.ts
toggleClassmateCommentLike.ts
投稿作成は、単にAPIを1回呼ぶだけではありません。
実際には、次のような流れがあります。
画像をData URL化する
投稿APIを呼ぶ
画像があればアップロードする
下書きを消す
未ログインなら下書きとpending submitを保存する
重複投稿なら編集画面へ誘導する
フィードに反映されるまで待つ
これをページコンポーネントに置くと、画面のコードが一気に重くなります。
そのため、createClassmatePost に寄せました。
const result = await createClassmatePost({
avatarFile,
input,
slug,
});
if (result.type === "needsLogin") {
navigate(
`/login?returnTo=${encodeURIComponent(`/g/${slug}/post-login-submit`)}`,
);
return;
}
if (result.type === "alreadyExists") {
navigate(`/g/${slug}/classmates/${result.classmateId}/edit`, {
replace: true,
});
return;
}
navigate(`/g/${slug}/feed`, { replace: true });
ページ側は、usecase の結果を受けて画面遷移するだけに近づきます。
APIの詳細、下書き保存、画像変換、未ログイン時の退避処理はページから遠ざかりました。
infrastructureへ寄せた処理
fetch、localStorage、sessionStorage、画像変換は src/infrastructure に置きました。
src/infrastructure/
api/
apiClient.ts
avatar/
avatarImage.ts
storage/
draftStorage.ts
pendingClassmateSubmit.ts
apiClient.ts では、API呼び出しだけでなく、CSRF token の付与や GET リクエストの重複抑制も扱っています。
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const requestInit = { ...init };
const method = requestInit.method?.toUpperCase() ?? "GET";
if (!["GET", "HEAD"].includes(method)) {
inFlightGetRequests.clear();
return requestWithCsrfRetry(path, init, true);
}
...
}
このあたりがページコンポーネントに散らばると、画面変更のたびに通信仕様まで気にする必要が出ます。
そのため、外部との接続は infrastructure にまとめました。
エッジケース
分割するときに気をつけたのは、正常系だけをきれいに見せないことです。
投稿作成では、次のようなケースがあります。
- 未ログインで投稿しようとした場合
- すでに同じグループに投稿済みだった場合
- 画像アップロードが必要な場合
- 投稿後、フィード取得のタイミングが遅れる場合
- 画像の形式やサイズが不正な場合
これらをページに残すと、ページが再び太ります。
一方で、すべてを汎用エラーハンドリングに押し込むと、画面遷移の意図が読みにくくなります。
そこで、usecase は結果を union type で返すようにしました。
export type CreateClassmatePostResult =
| { type: "created" }
| { type: "needsLogin" }
| { classmateId: number; type: "alreadyExists" };
ページは result.type を見て、必要な画面遷移だけを担当します。
これで、異常系を隠さずに、ページの責務も増やしすぎない形にできました。
やらなかったこと
今回、あえてやらなかったこともあります。
- 全 usecase に port/interface を作る
- domain 層を無理に作る
- 小さいUIまで全部1ファイル1コンポーネントにする
- 汎用的すぎる Button コンポーネントを作る
- 将来使うかもしれない抽象を先に用意する
特に port/interface は悩みました。
クリーンアーキテクチャ としては、application が infrastructure に直接依存しない方がきれいです。
ただ、今の規模でそこまで分けると、抽象を読むコストの方が大きくなると判断しました。
今回のゴールは、教科書どおりの構成にすることではなく、機能追加や修正のときに、読むべきファイルを自然に絞れる状態にすることでした。
まとめ
今回の分割で、App.tsx はほぼアプリの入口だけになりました。
ページは画面の組み立てに集中し、投稿作成や編集の手続きは usecase に寄せています。
API、storage、画像変換は infrastructure に移したので、外部依存の場所も追いやすくなりました。
一方で、厳密な クリーンアーキテクチャ にはしていません。
今の規模では、port/interface を増やすより、まず責務の混ざりを減らす方が効果的だと考えたためです。
クリーンアーキテクチャ をそのまま持ち込むのではなく、React アプリで扱いやすい範囲に薄く取り入れるくらいが、今回の規模にはちょうどよかったです。