結論 (2023/06/03 追記)
React の開発においては、
コロケーション Co-location の原則に従って、ファイルをディレクトリごとに分類しましょう。チームメイトや将来の自分にとって分かりやすいコードベースになります。
スキット「書くときは楽だけど...」
== 某日 ==
太郎くんの今日のタスクは、「トーストを作る」です。
太郎くん
「コンポーネントを作るから..」
「ファイルの場所は components/Toast.tsx
でええか。」
「useState
でローカルに状態管理して、表示を切り替えればええやろ。」
(src/) ... 以下略
└ components/
+ └ Toast.tsx
太郎くん「ヨシ!」
この記事は、拙スクラップの一項目をモノローグ形式で分かりやすくしたモノです。元のスクラップのほうが簡潔な解説です。
翌日
Slack 「スッコココ」
お客さま
@太郎くん すみません トーストに関するバグです。
『商品をカートに入れました』トーストが表示されてるときに
『通信エラーです』トーストを表示すると重なって表示してしまいます!
新しいトーストが出たら古い方は閉じてしまってください!
太郎くん
「しまった!」
「う~ん、どうしよう...」
「せや!トーストを開く関数を contextで渡せばええんや!」
「そんで Provider コンポーネントで状態を持って、」
「トーストの表示切り替えも、そこで管理すればええやろ!」
「トーストを表示するときはカスタムフックを通して呼び出す!」
「ワイ、天才やな~」
├ components/
│ └ Toast.tsx
├ providers/
+ │ └ ToastProvider.tsx
├ contexts/
+ │ └ toastContext.ts
└ hooks/
+ └ useToast.ts
=== 2年後 ===
このプロジェクトを引き継いだ後任ちゃん。
今日のタスクは「トースターにアイコンを追加する」です。
後任ちゃん
「type: "success" | "error"...
Propでアイコンを切り替えなアカンな」
「まずは Toast
コンポーネントにアイコン切り替えのロジックを書いて...っと」
カタカタ...
「で、このトーストの状態はどこで管理してんのやろ...」
(ため息)
「(VSCode)『参照へ移動』を使うか...」
「このコンポーネントを使ってるファイルは...」
「見つけた。providers/ToastProvider.tsx
やな。」
カタカタ
「ToastProvider
はtoastContext
を使ってるな。」
「しかし、contexts/toastContext.tsx
か...」
「これを通してトーストを利用するときには、hooks/useToast.ts
を使うんやな...」
「これも『参照へ移動』で見つけたわ」
「何でトーストひとつ治すのに、あちこちのディレクトリを探し回らなあかんの? 」
1.見かけ駆動パッケージングの災禍
何故こんな事になってしまったのでしょうか?
太郎くんが各ファイルを配置した場所を思い出してみましょう。
src/
├ components/
+ │ ├ Toast.tsx
│ └ (その他いろいろの Component)
├ providers/
+ │ ├ ToastProvider.tsx
│ └ (その他いろいろの Provider)
├ contexts/
+ │ ├ toastContext.ts
│ └ (その他いろいろの Context)
└ hooks/
+ ├ useToast.ts
└ (その他いろいろの Hook)
「コンポーネントだから
components/
」「Providerだからproviders/
」「Contextだからcontexts/
」「Hookだからhooks/
」
というように、各ファイルを「コードの形態だけで分類する」ようなディレクトリ構造になっています。
設計上のレイヤーによって分類することを「技術駆動パッケージング」と称し、アンチパターンだと耳にすることはありますが、これはレイヤーさえ考慮できていないので、「見かけ駆動パッケージング」といえるかも知れません。
凝集度のモノサシで見ると、そのディレクトリの中は「偶発的凝集」になっていると言えるでしょう。
トーストに関係するコードを探すときに
トーストの機能は hook と context と component と hook にまたがってるから、それぞれのディレクトリを探したろ!
なんてことを考えるでしょうか?しませんよね?
「トーストについてのコード」なんですから、toast/
という名前のディレクトリにまとめられている方が分かりやすいに決まっています。
コードを書くときに考えることが少なくて楽なのは「見かけ駆動パッケージング」であることは、さっきのスキットを見れば明らです。しかし、後からコードを読む同僚・未来のあなたは、苦しい思いをすることになります。
「トースト」という一つの関心事が、複数のディレクトリに散らばって、しかも沢山の無関係なファイルたちの中に埋もれてしまっているからです。
これじゃあ、関心の分離(separation of concerns)じゃなくて、関心の離散(tearing of concerns) ですね...
2.コロケーション(責務に従ったパッケージング)で解決
一例として、こんなディレクトリ配置を挙げておきます。
src/
├ components/ ... 汎用的なコンポーネント
├ consts/ ... 中央管理したい定数群
├ hooks/ ... 汎用的なカスタムフック
├ features/
│ ├ users/ ... ユーザー関連の機能
│ └ posts/ ... 投稿関連の機能
└ utils/
├ auth/ ... ページや操作の許可・不許可
+ └ toast/ ... トースト関連
+ ├ Toast.tsx
+ ├ toastContext.tsx
+ ├ ToastProvider.tsx
+ └ useToast.ts
features/
以下には、ビジネス概念・ページで区切った機能(feature)を配置して、
utils/
以下には、全体を横断して使われる、技術者視点で抜き出した個々の機能(concern)を配置しています。
(2023/06/03 追記)
このような形でファイルをディレクトリに固めるパターンは、 コロケーション Co-location と呼ばれています。 (動詞だと colocate)
(2023/02/20 追記)
libs/
と書いていたところを utils/
に変更しました。 (libs/
はライブラリをラップするのに使われることがあるため。)
utils/
は、「見かけ駆動」な分け方では「純粋関数だけを入れる場所」として使われがちでしたが、 ここでは「ファイルの形態に関わらず、横断的関心事を各々フォルダにまとめて配置する場所」としています。
これなら src/
ディレクトリを上から見ていくだけで、関係のあるファイルを一網打尽にして見つけ出すことが出来ますからね。
ただし、どの feature・concern にも属さない、汎用的なコンポーネントやカスタムフックは、それぞれ components/
, hooks/
に配置したり、文言のように中央管理したいモノも抜き出して /consts
のように配置するのも良いでしょう。
ほかにも、ToastコンポーネントのPropsと、状態管理の型を別々に切り離した上で、Toast.tsx
だけを components/
以下に移動するのもアリかも知れません。
3.小さく分けるのがラクになる
また、うれしい副作用があります。
features/**
, utils/**
の各ディレクトリの中に閉じている限りは、コードが散逸しないという安心感があるため、積極的に小さな部品(コンポーネントや関数、カスタムフック)に分けることができます。
(ただし、それぞれの名前はfeature名などを含めた長いものにする or eslint 等を駆使して import に制限をかける必要があります。)
大きなコンポーネント1つを作るのではなく、小さく分けて組み合わせる方が良い、ということは以下のスクラップが参考になると思います。
▼ こんな記事も書きました
【参考になった記事】
用語・説明の切り口は @MinoDriven 氏の諸記事・ツイートを参考にしました。