💡

React公式&SOLID原則からReactディレクトリ/コンポーネント設計を考察⚡

2024/09/14に公開

扱わないこと

この記事では具体的なReactディレクトリ/コンポーネント設計を扱いません。具体的なデザインパターンも扱いません。

では、何を紹介するのか?

Reactの思想とSOLID原則を用いると、どのようにReactディレクトリ/コンポーネント設計を考えることができるのかを考察したいと思います。

SOLID原則はいかなるシステム開発においても転用できる原則だと思います。
更に、Reactディレクトリ/コンポーネント設計は複雑になりがちで、TypeScript、状態管理ライブラリ、CSS、コンポーネントライブラリ、フェッチライブラリ、テストライブラリと考えることが多く、ケースバイケースだと思います。よって、具体的な解を持つよりも、抽象的な考え方を理解したほうが良いと思いました。

よいディレクトリ/コンポーネント構造はなんだろう?

シンプルに以下の2点だと思います。

  • 見つけやすい
  • 変更に強い

React公式の見解とSOLID原則

React公式

  • 機能ないしルート別にグループ化する
  • ファイルタイプ別にグループ化する
  • ネストのしすぎを避ける
  • 考えすぎない

https://ja.legacy.reactjs.org/docs/faq-structure.html

React公式でも、具体的な解を持っていません。

では、どのような原則で考えると、良いディレクトリ構成になるでしょうか?

そこで考え方の基本にしたいのが、SOLID原則です。

SOLID原則は、関数やデータ構造をどのようにクラスに組み込むのか、そしてクラスの相互接続をどのようにするのかといったことを教えてくれる。「クラス」という用語を使ったからといって、これらの原則がオブジェクト指向ソフトウェアにしか通用しないわけではない。ここでいうクラスとは、単にいくつかの機能やデータをとりまとめたものを指しているにすぎない。「クラス」と呼ぶかどうかは別として、どのようなソフトウェアシステムにもそのような仕組みはあるはずだ。SOLID原則は、そうした仕組みに適用するものである。  SOLID原則の目的は、以下のような性質を持つ中間レベルのソフトウェア構造を作ることだ。
●変更に強いこと
●理解しやすいこと
●コンポーネントの基盤として、多くのソフトウェアシステムで利用できること
Clean Architecture 達人に学ぶソフトウェアの構造と設計 より

オブジェクト指向で使われることが多い原則ですが、「よいディレクトリ/コンポーネント構造はなんだろう?」で定義したものと、SOLID原則の目的はほぼ一致しています。

Reactの思想とSOLID原則をまとめると以下となります。

  • 機能ないしルート別にグループ化する
  • ファイルタイプ別にグループ化する
  • ネストのしすぎを避ける
  • 考えすぎないこと
  • 単一責任の原則
  • オープン・クローズドの原則
  • リスコフの置換原則
  • インターフェイス分離の原則
  • 依存関係逆転の法則

それぞれどのように適用するか考察してみます。

機能ないしルート別にグループ化する

「機能ないしルート別にグループ化する」は以下のような感じだと思います。

/src/pages
└── /Foo
    ├── Foo.tsx
    ├── useData.ts
    ├── index.css
    └── types.ts

個人的に、ファイルを探しやすいから良いと思いました。
例えば、Foo画面を作るときに、Foo配下でhooksを探したり、cssファイルを探したりすることができるので、開発効率が良いと思いました。

また、実装者ごとの表記揺れも抑えられると思います。
画面ごとに実装をすることが多いと思うので、実装者ごとに命名や書き方の差異が各ディレクトリに閉じられるのは良いことだと思います。

ファイルタイプ別にグループ化する

「ファイルタイプ別にグループ化する」はこんな感じだと思います。

/src
├── /pages
│   └── /Foo
│       └── Index.tsx
├── /hooks
├── /css
└── /types

「機能ないしルート別にグループ化する」とコンフリクトする概念だと思います。

個人的には、ファイルタイプ別(役割ごと)に分けると、ディレクトリを行ったり来たりで効率悪いので、あまりよくないと思いました。

が、あえて当てはめるとするならば、フォルダは「機能ないしルート別にグループ化する」、フォルダ内のファイルは「ファイルタイプ別にグループ化する」が良いなと感じています。

以下のような感じで、機能ごとにフォルダを作って、その中でhooks,css,typeなどなどの定義ファイルを分けるみたいな構成が良いと思います。

/src/pages
└── /Foo
    ├── Foo.tsx
    ├── useData.ts
    ├── index.css
    └── types.ts

ネストのしすぎを避ける

あまりにも深いとファイルを探すのが大変になりそうです。
階層が深くなりそうであれば、フォルダを新しく作ってまとめられないかを検討するで良いのかなと思いました。

考えすぎないこと

React公式が、迷ったら全部ひとつのフォルダへと言っています。

Reactはある程度成熟した技術なので、デザインパターンも豊富だと思います。有名なデザインパターンを参考にすれば大きく間違えることはなさそうです。

単一責任の原則

「1つのクラスは1つだけの責任を持たなければならない。」という考えです。

ファイルの分割は、責務ベースでするべきだと思います。

例えば、UIを表示するという責務であれば行数がある程度増えても気にしなくてよいと思っています。
一方で、行数が多いということは、共通化、関数化できるのでは?と疑うべきであるとも思います。

共通化するということは、カスタマイズ性が失われることの裏返しでもあると思うので、行き過ぎた共通化は避けたほうが良いなと思いました。
例として、機能を拡張するにつれて、propsの値によってUIや振る舞いを変えるコンポーネントができると思います。僕は、その時ファイル(コンポーネント)を分けたほうがメンテナンス性が高いと思います。

また、共通化するか迷うコンポーネントも、必要になったタイミングで共通化のほうが良いと思っています。

オープン・クローズドの原則

「コンポーネントや関数の拡張に対しては開いて、変更に対しては閉じているべき」という考えです。

既存のコードに修正を加えずに、新しくコードを追加するだけで対応できるような設計と解釈しています。

そのために、コンポーネントを細かく分けることが重要だと思います。

例えば、最小単位のコンポーネントとしてButton.tsxがあるとします。

Button.tsx
import React from 'react';

interface ButtonProps {
  label: string;
  onClick: () => void;
}

export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return (
    <button onClick={onClick}>
      {label}
    </button>
  );
};

上記のButton.tsxを拡張して、LoginButtonを作ることができます

LoginButton.tsx
import React, { useState } from 'react';
import Button from './Button';

interface LoadingButtonProps {
  label: string;
  onClick: () => Promise<void>;
}

export const LoadingButton: React.FC<LoadingButtonProps> = ({ label, onClick }) => {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);
    try {
      await onClick();
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button
      label={loading ? 'Loading...' : label}
      onClick={handleClick}
    />
  );
};

既存の最小単位のButtonコンポーネントを修正せずに、外部から新しい機能や拡張したボタンを作ることができています。

リスコフの置換原則

「S型のオブジェクトo1の各々に、対応するT型のオブジェクトo2が1つ存在し、Tを使って定義されたプログラムPに対してo2の代わりにo1を使ってもPの振る舞いが変わらない場合、SはTの派生型であると言える」という原則です。

僕の解釈だと、親クラスを使う場面では、子クラスを代わりに使っても、プログラムの内容が変わらないことを保証するというような原則と思っています。

これは転用するのが難しい概念だなと思うのですが、Reactディレクトリ/コンポーネント設計においては、propsの型の継承という形で適用します。

Button.tsx
interface ButtonProps {
  label: string;
  onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

以下は、Submitbuttonへの拡張の例です。
子コンポーネントButtonで設定できること(ここでは、label、onClick)は、親コンポーネントであるSubmitButtonでも設定できることを示しています。

副次的に、Button.tsxの型を継承することによって、SubmitButtonだけに必要なisSubmittingが明示的に示されているのもよいですね。

Submitbutton
interface SubmitButtonProps extends ButtonProps {
  isSubmitting: boolean;
}

const SubmitButton: React.FC<SubmitButtonProps> = ({ label, onClick, isSubmitting }) => (
  <Button
    label={isSubmitting ? 'Submitting...' : label}
    onClick={onClick}
  />
);

インターフェイス分離の原則

不必要な依存をなくすという考えです。

不必要なpropsを渡さないという解釈で転用できそうです。

例えば、読み取り専用のフォームと編集用のフォームを準備する場合です。
propsでUIやイベントを分けることも考えられそうですが、インターフェイス分離の原則を適用すると以下のように分けられると思います。

読み取り専用モード
interface ReadOnlyUserInfoProps {
  userName: string;
  userEmail: string;
}

const ReadOnlyUserInfo: React.FC<ReadOnlyUserInfoProps> = ({ userName, userEmail }) => (
  <>
    <p>{userName}</p>
    <p>{userEmail}</p>
  </>
);
編集モード
interface EditableUserInfoProps {
  userName: string;
  userEmail: string;
  onChangeName: (name: string) => void;
  onChangeEmail: (email: string) => void;
}

const EditableUserInfo: React.FC<EditableUserInfoProps> = ({ userName, userEmail, onChangeName, onChangeEmail }) => (
  <>
    <input type="text" value={userName} onChange={(e) => onChangeName(e.target.value)} />
    <input type="email" value={userEmail} onChange={(e) => onChangeEmail(e.target.value)} />
  </>
);

もし1つのコンポネートにした場合はこのようになります。

読み取りと編集を共存
interface UserInfoProps {
  userName: string;
  userEmail: string;
  isEditable: boolean;
  onChangeName?: (name: string) => void;
  onChangeEmail?: (email: string) => void;
}

const UserInfo: React.FC<UserInfoProps> = ({
  userName,
  userEmail,
  isEditable,
  onChangeName,
  onChangeEmail
}) => {
  if (isEditable && onChangeName && onChangeEmail) {
    return (
      <>
        <input
          type="text"
          value={userName}
          onChange={(e) => onChangeName(e.target.value)}
        />
        <input
          type="email"
          value={userEmail}
          onChange={(e) => onChangeEmail(e.target.value)}
        />
      </>
    );
  } else {
    return (
      <>
        <p>{userName}</p>
        <p>{userEmail}</p>
      </>
    );
  }
};

前者のほうがパット見でもスッキリしてると思います。

編集のときだけ、イベント関数(onChangeName、onChangeEmail)を使用したいので、読み取りのときに渡す必要はないです。
不要なpropsを渡すのであれば、ファイルを分けてしまったほうが可読性が良いです。

依存関係逆転の法則

上位コンポーネントで通信を行い、propsを渡すことによって、汎用性を高める事ができます。
上位コンポーネントから下位コンポーネントにpropsを渡して、DI(依存関係の注入)することによって、疎結合性を高めます。それにより、上位コンポーネントは下位コンポーネントに依存していません。

蛇足: README.mdに書いておく

ディレクトリ構成や、コンポーネント設計はREADME.mdに書いておいたほうが、秩序を保った開発ができると思います。

/src
└── components
    ├── Foo.tsx
    └── types.ts


-   `src/ components/Foo/index.tsx`: Foo機能のUIコンポーネントです
-   `src/ components/Foo/types.ts`: Foo機能の型定義ファイルです

最後に

SOLID原則をそのまま使うことは難しかったですが、SOLID原則の理解が深まりました。

SOLID原則はいかなるシステム開発においても転用できうる原則だと思うので、Reactディレクトリ/コンポーネント設計のみならず、色々な設計に取り入れたいなと思いました♫

エックスポイントワン技術ブログ

Discussion