コンポーネントの分類について考えたことをまとめた
どのようにコンポーネントを分類していくか
個々のコンポーネントがが単一の責任のみ負っている状態であれば、コードの見通しも良くなる上、後々のメンテナンスも容易です。
まずは「複数の関心ごとを1つにまとめない」という原則(単一責任の原則)から分類の指針を考えていきます。
フロントエンドにおける関心ごとですが、大別すると
- APIとの接続
- View(表示とイベント実行)
- 状態管理
以上3つに集約されるのではないでしょうか。
では、これらの関心ごとをどの様に切り分けていけば良いのか考えていきます。
APIとの接続
まずはAPIとの通信ですが、こちらはコンポーネントの外で管理した方が良いと考えています。
各コンポーネントに通信に関わる処理がベタ書きされている状態だと、エンドポイントの変更等に弱くなり、通信処理の再利用も面倒です。
それに、「フロントの状態管理 / 表示」と「外部との接続」はそれぞれ別種の関心ごとであり、それを同一に扱うことは避けた方が良いでしょう。
以前携わっていたPJではRedux toolkitを導入していましたので、APIとの通信処理を別ファイルの関数に切り分けた上で、Redux-thunkにて利用する形にしていました。
これによりAPIに関する知識を1つのレイヤーに閉じ込めることができた上、将来的にRedux-thunkから乗り換えることも容易になりました。
Viewと状態管理
Viewと状態管理のレイヤーを切り分ける場合、良く使われるパターンが、「表示を担当するコンポーネント(presentational)」と「データの取得やロジックを担当するコンポーネント(container)」という分類です。
React hooksの導入以降、「Hooks時代にはContainerが必要ない」という言説も耳にする様になってきましたが、「状態(HooksやRedux)とコンポーネントを接続させる」という役割としては、まだまだ有用な考え方だと思っています(開発の規模感次第でもありますが)
メリット1. コンポーネントの検証がしやすくなる
Presentational コンポーネントとContainer コンポーネントに分割することにより、Presentational コンポーネントからロジックを取り除くことができます。
それにより、Presentational コンポーネントから外部への依存(ReduxやAPIとの通信等)を排除することが可能です。
この様に実装されたPresentational コンポーネントは、単にPropsを受け取って表示を返すだけの関数であり、外部との結合度はゼロに等しくなります。
つまり、いわゆる Humble Object パターンですね。それにより、テストやStoryBookによる検証が非常に楽になります。
メリット2. StoryBookで扱いやすくなる
メリット1と関係している部分でもあるのですが、外部に依存していないPresentational コンポーネントというのはStoryBookで非常に扱いやすいです。
メリット3. データを柔軟に変更できる
ローカルstateで状態を持っていたり、Presentational コンポーネントから直接Redux等を参照している場合、その値をPropsとして渡したくなった際に困ります。
最初からPropsで値を受け取る様にしておけば、その値をどこから取得していようが、Presentational コンポーネントは関与しません。
Presentational コンポーネントで保持すべきState
では全てのStateをContainerで保持するべきかというと、そんなことはありません。
そのUI自体に属する様なStateは、Presentational コンポーネント内で保持する方がいいでしょう。
具体的には、メニューの開閉状態などがそれに当たります。
どの単位でContainerを作成するのか
Propsのバケツリレーを防ぐという観点からすると、UIの最末端を含むあらゆるコンポーネントでContainerを作成したくなります。
しかし、
- あらゆる部分でContainerを作成するのは煩雑ではないか
- 状態管理の単位がUI中のあらゆる箇所に散逸するとカオスにならないか
といった問題を考慮すると、ある程度のバケツリレーを許容しつつ一定のルールのもとでContainerを作成していきたいところです。
ルール1:Organisims単位でContainerを作成する
以前携わっていたPJでは、このようなシンプルなルールで開発を進めていました。
OrganismsとはAtomic Designで用いられるコンポーネントの単位であり、独立して成立できる、スタンドアローンなコンテンツを指します。一言で言うなら、俗に「〇〇エリア」と呼ばれる様なものです。
メリット
- 意味を持った1まとまりの状態を管理する単位としてフィットする
- とにかくシンプルに分類できる
- バケツリレーの階層も深くなりすぎない
デメリット
- 末端のコンポーネントで管理したい状態も、Organisims単位のContainerから渡す必要がある
開発を進める中で実感したのですが、このデメリットがとにかく大きかったです。
例えば、「ドロップダウンで都道府県を選択するコンポーネント(以下、都道府県コンポーネント)」を実装する場合、Organisms内で呼び出す末端のコンポーネントとして実装することになると思います。その場合、
- Container(Organisims単位)で都道府県一覧をAPIから取得
- 都道府県コンポーネントにPropsとして渡す
という工程が必要になります。
都道府県コンポーネントを1つのOrganisimsでしか使用しないならさほど問題にもなりません。
しかし、複数のOrganisimsで利用する場合、上記の工程を毎回繰り返す羽目になってしまいます。
Reactの場合、データ取得をhooksに切り出せば処理をまとめられますが、その場合は「都道府県コンポーネントを内包するContainerコンポーネント」とHooksの間に暗黙の依存関係が生じることになりますし、そのHooksの存在を常に周知させておくコストもかかります。
データ取得の処理を切り出した場合でも、それを呼び出すのは都道府県コンポーネント単位にて行いたいところです。
ルール2:domainコンポーネントとcommonコンポーネント
上記の反省点を踏まえ、下記のような「domainコンポーネント」と「commonコンポーネント」への分類というルールを考えています。
- domainコンポーネント
- 何らかのドメインに紐付く形で分類する
- 例:todo/components/todoList.tsx
- Containerを作成できる(状態管理の単位となる)
- 内部で下記のcommonコンポーネントを呼び出すこともある
- 何らかのドメインに紐付く形で分類する
- commonコンポーネント
- ドメインに関心を持たない
- commonComponents/heading.tsx
- Containerを作成できない
- UIの状態(メニューの開閉など)以外のロジックは持たない
- 中規模以上の開発の場合、 commonComponents 内部でさらにディレクトリを切る
- ドメインに関心を持たない
具体的なソースコードの例は下記となります。
/**
* Type
*/
type TodoListProps = {
list: {
title: string;
id: string;
count: string;
}[];
handleClick: (id: TodoListProps['list'][number]['id']) => () => void;
};
/**
* presentational component
*/
// ここで presentational component を実装せず、
// container内でcommonコンポーネントを呼び出すこともある
export const TodoList: FC<TodoListProps> = ({ list, handleClick }) => (
<>
<ul>
{list.map((item) => (
<li key={item.id}>
<p>{item.count}</p>
<p>{item.title}</p>
<button type="button" onClick={handleClick(item.id)}>
delete
</button>
</li>
))}
</ul>
</>
);
/**
* container component
*/
// 凝集性を考慮し、containerは同一ファイル上に作成
// hooks等を呼び出し、状態をPresentationalコンポーネントに渡す
// container in container も許容する
export const TodoListContainer = () => {
const dispatch = useDispatch();
const handleClick = (id: TodoListProps['list'][number]['id']) => () => dispatch(todoSlice.actions.deleteToDo(id));
const list = useSelector(todoListSelector);
useEffect(() => {
dispatch(asyncFetchTodoList());
}, [dispatch]);
return (
<TodoList handleClick={handleClick} list={list} />
);
};
/**
* Type
*/
type HeadingProps = {
text:string;
}
/**
* presentational component
*/
export const Heading:FC<HeadingProps> = ({text}) => {
return <h1>{text}</h1>
}
この分類ならば、domainコンポーネントとして都道府県フォームを実装し、その中の Container コンポーネントでhooksを呼び出せば、前述のような「別々の Container で毎回Hooks を呼び出す問題」を解決できます。
まだ個人PJで素振りした段階ではありますが、
- 特定のドメインに紐付くコンポーネントを一箇所にまとめられる
- 汎用コンポーネントとそれ以外の切り分けがはっきりする
- コンポーネントの関心に沿い、状態を分散管理する上でも都合が良い
といったメリットも感じていますので、引き続きこの方針を試していきたいです。
余談:Presentational コンポーネント実装時の注意点
本題からは外れますが、Presentational コンポーネント実装時に陥りがちな(というよりも実際に陥った)罠についても簡単に書いておきます。
コンポーネント指向による実装を考えると、各コンポーネントを汎用的なものとして実装しがちです。
末端の小さなコンポーネント(いわゆるAtomsやMolecules)だとその方向性でも問題ありませんが、粒度の大きなコンポーネント(いわゆるOrganisms)を汎用コンポーネントとして実装しようとすると途端に破綻します。
理由
- コンポーネントは大小様々な修正を繰り返す箇所。共通コンポーネントに見えても、場所によって微妙に仕様が異なることも起こりうる
- 粒度が大きくなるほど、当然内部の実装も複雑化する。それを一まとめに共通化すると、保守性が悪化する
- 上記2つが合わさると、複雑な実装+ページ固有の条件分岐による複雑怪奇なコンポーネントが爆誕する
コンポーネントの共通化を図る際は、小さなコンポーネント単位で行うのがオススメです。
その程度の粒度であれば実装も複雑化しづらく、ページ固有のパーツが必要になった際の差し替えも容易です。
Discussion
コメント失礼します。
domein→domainではないでしょうか?
お恥ずかしい…
ご指摘ありがとうございます!
いえいえ、こちらこそ参考になる記事でした!