📂

スケールしやすいReactフォルダ構造

2023/01/05に公開

モジュール型+木構造

私のプロジェクトでは、フォルダ構造に迷走した末、モジュール型+木構造のフォルダ構造を採用することで、スケールしやすいReactフォルダ構造になりました。

この構造では、機能(関心)ごとにフォルダを分け、そこに機能に関係したものだけを入れます(folder by feature)。
またフォルダにはindexファイルがあり、フォルダ外にexportするものはここに記述します。
つまり、フォルダをモジュールのようにとらえ、indexファイルをインタフェースとするような感じです。
このモジュールのようなフォルダ構造は、「re-ducksパターン」としても知られています。
AngularやNestjsの構造にも近く、それらが得意とする堅牢な開発に近づきます。

そして、さらに機能を支える機能が必要になったり、機能で使うコンポーネントが複雑になった場合、これをまた上記のようなモジュール型フォルダにして、フォルダの下に配置します。
そうすると大きな機能に小さな機能がつく、木構造のディレクトリ構成になります。

フォルダ構造の例

src
├── api
├── components
├── features
│   ├── auth
│   │   ├── components
│   │   │   └── loginModal
│   │   │       ├── api
│   │   │       ├── stores
│   │   │       └── index.tsx
│   │   ├── routes
│   │   └── index.ts
│   └── user
│       ├── components
│       ├── routes
│       │   └── profile
│       ├── stores
│       │   ├── entities.ts
│       │   └── ui.ts
│       └── index.ts
├── lib
├── routes
├── stores
├── types
└── index.ts

※フォルダ名はReactのベストプラクティスをまとめたBulletproof Reactから影響を受けています(もちろんこの通りでなくてもよいです)

新しい機能を作る場合は、以下のようにフォルダを作成します

src
└─── features
    └── newFeature
        ├── components
        ├── lib
        ├── states
        └── index.ts

この時、index.ts で export するものは、以下のようにします

export {NewFeatureComponent} from './components/NewFeatureComponent';
export {NewFeatureLib} from './libs/NewFeatureLib';
export {NewFeatureState} from './states/NewFeatureState';

モジュール化の利点

モジュール型のフォルダの利点は、そのままモジュール化(or 関心の分離・カプセル化)と同じような恩恵が得られることにあります。

仮にすべてのコンポーネントを上の階層にまとめて置いたとします、はじめはいいのですが規模が大きくなるにつれ、フォルダは大量の再利用されないコンポーネントで埋め尽くされるでしょう。
私の場合「アトミックデザイン」をそのままフォルダに落とし込んでいましたが、後に大量のページ専用のコンポーネントだらけになり苦しみました。
そこで、機能内でしか使われない(プライベートな)コンポーネントはモジュール内に閉じ込め、機能外でも使うような(パブリックな)コンポーネントは、indexファイルでexportするか上の階層に置くとすっきりします。

つまり、関心ごとはコンパクトにまとめることで、機能全体の把握がしやすいということです。

木構造の利点

木構造の利点は、まずプロジェクト全体の構造が把握しやすくなることにあります。
並列的にずらっと並んだ機能から探すよりは、マインドマップのように機能をたどった方が把握しやすいという考えです。
そもそも機能をもとに機能を作るという関係は、プログラムのそれと同じなので、分けやすいです。

さらに、依存関係がわかりやすくなるという利点もあります。
依存関係の複雑化は、大規模プロジェクトの開発が遅くなる大きな要因でもあります。
上のフォルダが下のフォルダをindexファイル(インタフェース)を介してしか使えなくすることで、影響や関連性を管理しやすくなります。

ただ、下のフォルダが上のフォルダをどこまで利用できるようにするかどうかについては、ちょっと考える必要があります(引数で依存性の注入をするというのもつらい)。

下位フォルダに上位フォルダの機能を使わせるか

下のフォルダが 判定 理由
上の「副作用のない」機能を使う o 一方向の依存関係が保たれるため
上の「副作用のある」機能を使う 循環参照になりうるため
上の「状態」を使う 状態が適切に管理されていればOK

下のフォルダが上の「副作用のない」機能を使う

副作用のない機能は、例えば、必ず同じ結果を返すユーティリティや、UIライブラリ(アトミックデザインにおけるAtoms)などです。
どこにも依存していなければ、そこで依存関係が止まるので、どこから使っても影響は少なくすみます。
副作用のない機能・ある機能はフォルダを分けるなどするといいでしょう。

下のフォルダが上の「副作用のある」機能を使う

これをすると、上から下へ流れるような綺麗な依存関係から、複雑なクモの巣状の依存関係になってしまいます。
ただ、ほかのモジュールが外部利用を前提として機能を出力しているものはありとします。つまり、featureのindexがexportしている機能です。
それは外部での利用を前提としているため、副作用が比較的少ないことが期待できるからです。
(DI経由で利用する手もありますが、それだとprops drilling(バケツリレー)になりがちなので、機能を組み換える予定がなければ、こちらでいいと思います)

下のフォルダが上の「状態」を使う

「状態(カスタムフックや状態管理ライブラリなど)」はある意味副作用そのものと言えます。
ゆえにこれも先ほどの、「副作用のある」機能を使う場合と同じです。
下位の状態はできるだけ共有しないほうがいいです。

しかし下が上の状態を「見る」場合はどうでしょうか。
そもそも状態管理ツールは、バケツリレー回避のため、依存関係を飛び越えて利用することを想定しています。
フロントエンドをやる以上は状態(副作用)を排除することはできません。依存を一方向にするための状態のバケツリレーは実装の効率が落ちます。
したがって、下位フォルダの副作用は容認し、状態変化による影響をできるだけ減らすをことになります。
例えば、

  • 状態の更新に制限を設ける
  • 状態はできるだけ下位のフォルダに置く
  • コンポーネント内でしか利用しない状態はuseStateで完結させる

などの工夫ができます。依存関係グラフがきれいになればよいのです。

また、状態管理ライブラリなどは、状態の変更(actionなど)に制限を設けられます。
これは、状態(依存)の管理をしているので、ある意味DIコンテナとも言えるでしょう。
この状態の変更をするためのアクションは、モジュールのインタフェース(やメッセージパッシング)と同じような働きがあります。
それは外部での利用を前提としているため、副作用が比較的少ないことが期待できます。

Discussion