なぜこの記事を書こうと思ったのか
React の開発におけるディレクトリ構成には正解がなく、
一般的な選択肢として Atomic Design や、近年人気の Bulletproof-React などがよく採用されています。プロジェクトごとに独自のディレクトリ構成が用いられるケースも少なくありません。
しかし、これらの手法は一見すると取っつきづらく、「チーム内での共通理解を深めるための設計として本当に最適なのか?」 という疑問もあります。
そこで、現在の開発現場で採用している新しい AtomicDesign の構成 が、
「煩雑化を防ぎつつ、チーム内で理解しやすいディレクトリ構成を実現できている」 と感じたため、その考え方や実践方法を記事としてまとめました。
改めてAtomicDesignって何かと?
前置きとして書くとだらだら長引いてしまうため、詳細は以下参照ページをご確認ください。
AtomicDesignにはメリットもデメリットもある
メリット
- コンポーネント単位で管理できるため、構造がシンプルで分かりやすい
- 1ファイルあたりのコード量が少なくなり、可読性が向上する
- 採用時に参考となる記事や実績が多く、導入に関する議論が最小限で済む
デメリット
- atoms, molecules, organisms の境界が曖昧になりやすく、管理が煩雑になりがち
その結果、プロジェクトごとに独自ルールが生まれ、統一性を保ちにくい - どこに何があるのかわからなくなりやすい
- 定義のブレ によって、コンポーネントの配置がバラバラになり、探索コストが増える
- 定義がブレる影響が割とコードレビューを長引かせる原因にもなる。
- organisms増えすぎ問題
なので広くは一般的に知られているけど、どこでも同じようには使っていないような。そんな感じですね。
それならばAtomicDesignのベースにして、ちょっと内部定義のルール変えてみるのも手だなと思いました。
そして出来ればこのまま新しいAtomicDesignで今後も開発したいなぁと思っています。
新しいアトミックデザイン構成の定義
用語は変えずに内部のものを変えました。
階層 | 用途 | 使用回数 | 詳細 |
---|---|---|---|
atoms | 汎用 | 複数回 | Button や TextField など、1つのタグのみを返すコンポーネント(div などのラッパー要素を含まない)内部ロジックを持たない |
molecules | 汎用 | 複数回 | atoms や molecules を組み合わせ、複数のタグを返すコンポーネント(ラッパー要素をもつ)内部ロジックを持たない |
organisms | 限定 | 1回 | 内部に useState や API 通信 (fetch, axios) を含む、プロジェクト固有のコンポーネント(再利用しない) |
template | 限定 | 1回 | organisms を組み合わせた構成要素。従来の Atomic Design 同様、ロジックは持たないが、organisms 間で state を共有する場合は定義可能とする。 |
pages | 限定 | 1回 | template をラップし、ページ全体の構成を担う(従来の Atomic Design と同様) |
atomsのサンプル
※ muiをベースにしているためデフォルトスタイルに変更を意味する、「Costom」のprefix「C」が付与されています。
Button や TextField など、1つのタグのみを返すコンポーネント(divなどのラッパー要素を含まない)
①背景色が塗りつぶされているボタン
import { Button, ButtonProps } from "@mui/material";
import { FC } from "react";
type Props = ButtonProps;
const CButtonContained: FC<Props> = ({ children, ...other }) => {
return (
<Button variant="contained" {...other}>
{children}
</Button>
);
};
export default CButtonContained;
②外枠線付きのボタン
import { Button, ButtonProps } from "@mui/material";
import { FC } from "react";
type Props = ButtonProps;
const CButtonOutlined: FC<Props> = ({ children, ...other }) => {
return (
<Button variant="contained" {...other}>
{children}
</Button>
);
};
export default CButtonOutlined;
③入力欄
import { TextField, TextFieldProps } from "@mui/material";
import { ChangeEvent, FC } from "react";
type Props = { value: string; onChange: (e: ChangeEvent<HTMLInputElement>) => void } & TextFieldProps;
const CTextField: FC<Props> = ({ value, onChange, ...other }) => {
return <TextField value={value} onChange={onChange} {...other} />
};
export default CTextField;
上記3件のatomsのディレクトリ構成
コンポーネントへの単体テストやstorybookもディレクトリ内部に配置します。
atoms
├── CButtonContained
│ ├── index.stories.tsx (storybook)
│ ├── index.test.tsx (jest,vitestによる単体テスト)
│ ├── index.tsx (ルートファイル)
│ └── style.ts (コンポーネントのスタイル定義)
├── CButtonOutlined
│ ├── index.stories.tsx
│ ├── index.test.tsx
│ ├── index.tsx
│ └── style.ts
└── CTextField
├── index.stories.tsx
├── index.test.tsx
├── index.tsx
└── style.ts
moleculesのサンプル
atoms や molecules を組み合わせ、複数のタグを返すコンポーネント(ラッパー要素をもつ)
①ユーザー名とフォローボタンを持つカードコンポーネント
import { FC } from "react";
import { Typography } from "@mui/material";
import CButtonOutlined from "../atoms/CButtonOutlined";
type Props = {
name: string;
onFollow: () => void;
};
const CUserInfo: FC<Props> = ({ name, onFollow }) => {
return (
<div> //ラッパー要素を持つためmoleculesとする
<Typography>{name}</Typography>
<CButtonOutlined onClick={onFollow}>フォロー</CButtonOutlined>
</div>
);
};
export default CUserInfo;
moleculesのディレクトリ構成
atomsとほぼ同様のためサンプルファイルは1つとしました
こちらもatomsと同様に汎用性があるためstorybookと単体テストコードがコンポーネントディレクトリ内で格納されています。
molecules
└── UserInfo
├── index.stories.tsx
├── index.test.tsx
├── index.tsx
└── style.ts
organisms
内部に useState や useEffect, hooks呼び出し、 API 通信 (fetch, axios) を含む、プロジェクト固有のコンポーネント(再利用しない)
また「organismsが増えすぎる」というのもAtomicDesignの欠点でした。ここを各ページごとに1階層、ディレクトリで区切ることで可読性面で解消します。
- 単体テスト非対応(E2E テストで検証) -> 通信を前提とするものが多いため
- 汎用性がない限定的なコンポーネント群のため、Storybook も対象外
- atoms / molecules と違い、organisms は各ページごとにディレクトリを配置
- 親子関係をコンポーネントの名称で「チェーン的」に示す
- ディレクトリのソート順で関連コンポーネントが近くに配置されるため、関連性が直感的に読み解きやすい
organisms
├── App(アプリケーション全体で使用する固定コンポーネント)
│ ├── Footer
│ │ ├── index.tsx
│ │ └── style.ts
│ └── Header
│ ├── index.tsx
│ └── style.ts
└── top (トップページ専用のコンポーネント群)
├── Contents
│ ├── index.tsx
│ └── style.ts
├── ContentsDetailModal (← 詳細を表示するモーダル)
│ ├── index.tsx
│ └── style.ts
├── ContentsForm (← フォーム)
│ ├── index.tsx
│ └── style.ts
├── ContentsFormSubmitModal (← フォームの送信確認モーダル ※以下でサンプルコード)
│ ├── index.tsx
│ └── style.ts
...
organismsサンプルコード
import React, { FC } from 'react'
type Props = {
}
// コンポーネント名称には親ディレクトリの名称を付与(Top)して誤import防止
const TopContentsFormSubmitModal:FC<Props> = ({}) => {
// useState や API 通信 (fetch, axios) などの独自ロジックを記入する
return (
<div>返却されるUIを記入</div>
)
}
export default TopContentsFormSubmitModal
templateとpagesについて。
こちらについては、ほぼ既存のアトミックデザインと同様になっているので説明としては省略します。
個人的なルールとしては、
- 複数のorganismsに対してデータをpropsの参照として引き渡さなくてはならない場合は、pages上にてFetch関数の定義を実施して、templateにpropsで引き渡す -> 各organismsへ。としています。
- organisms間でどうしてもデータの共有が必要なケースなどではtemplate上でのstate定義も許容してます(わざわざpagesで定義してpropsで落とし込むメリットがない、template上でstateにコメントでも残しておいてくれれば問題なし。)
ぐらいでしょうか。あまり本家と大差がないですね。
最後に
ほんとはAtomicDesignにしろ、Bulletproof-Reactをしっかりと確立されているものとしたいものですが....
現場チームメンバー間での取り決めの共有などでの時間の浪費っぷりが問題だなぁと考え、わかりやすく簡潔な構成を目指して作成したディレクトリ構成になります。
この記事がきっかけで、より安定したフロントエンド開発が出来るようになることを願っております。
ありがとうございました。