Reactコンポーネント管理を最適化するAtomic Design導入ガイド

目次

「コンポーネントが増えてきたら、どのフォルダに置けばいいか分からなくなってきた」——React 開発でチームが直面する最初の設計課題が、コンポーネントのディレクトリ構成です。ルールがなければファイルが散在し、ルールが厳格すぎれば分類に迷って手が止まります。本記事では、UI 設計の方法論として広く採用されている Atomic Design を React プロジェクトへ適用する具体的な方法を、ディレクトリ構成・コード例・よくある迷いどころと対策まで体系的に解説します。

Atomic Design とは:5層の階層構造

Atomic Design は Brad Frost 氏が提唱した UI 設計の方法論で、コンポーネントを「原子(Atom)→分子(Molecule)→有機体(Organism)→テンプレート(Template)→ページ(Page)」の5層に分類します。化学の世界で原子が結合して分子を形成し、より複雑な構造へと発展していくように、UI もシンプルな部品から複雑な画面へと積み上げていきます。

React との相性が良い理由は、この方法論が「コンポーネントの責務を明確にする」という React の設計思想と自然に一致しているからです。どの層にどのコンポーネントを置くかが決まっていると、新しいメンバーがコードベースに入ったときも迷いが生まれにくくなります。

各層の定義と分類基準

Atomic Design の各層を、React プロジェクトでの具体的な分類基準とともに解説します。

  1. Atoms(原子): 最小単位。Button、Input、Label、Icon、Badge、Spinner、Avatarなど。外部の状態(ContextやAPI)に依存せず、propsのみで動作します。
  2. Molecules(分子): 複数のAtomを組み合わせ、単一の機能を持つ単位。FormField(Label + Input)、SearchBar(Input + Button)、Card(Image + Title)など。
  3. Organisms(有機体): MoleculeやAtomを組み合わせた独立した機能ブロック。Header、LoginForm、ProductCard、CommentListが該当します。ここからビジネスロジックやAPI連携を持つ場合があります。
  4. Templates(テンプレート): ページのレイアウト構造。実際のデータ(コンテンツ)は持たず、「どこに何を配置するか」を定義するワイヤーフレームの役割を担います。
  5. Pages(ページ): 最終的な画面。Templateに実データを流し込み、API呼び出し、認証チェック、ルーティングなどの副作用を管理する司令塔です。
エンジニア

「Atom と Molecule の境界線はどこ?」という迷いが一番多いですね。自分の判断基準は「その UI に名前が付けられるか」。FormField や SearchBar のように1つの役割を言葉で表せるなら Molecule、そうでなければ Atom に置いています。

デザイナー

デザインの観点だと、Atom はデザイントークン(色・サイズ・フォント)を直接使うレベルで、Molecule 以上はデザインシステムのコンポーネントとして定義するレベルのイメージが近いです。Storybook の Stories を書く単位とも自然に対応します。

ディレクトリ構成の実践例

Atomic Design を React プロジェクトに適用した場合の、具体的なディレクトリ構成例です。laravel-react-boilerplate の構成を参考にしています。

src/
├── components/          # Atomic Design によるコンポーネント群
│   ├── atoms/           # 最小単位:Button、Input、Label など
│   │   ├── Button/
│   │   │   ├── index.tsx          # コンポーネント本体
│   │   │   ├── index.stories.tsx  # Storybook の Story
│   │   │   └── index.test.tsx     # Vitest / Testing Library のテスト
│   │   ├── Input/
│   │   └── Icon/
│   ├── molecules/       # Atom の組み合わせ:FormField、SearchBar など
│   │   ├── FormField/
│   │   └── SearchBar/
│   ├── organisms/       # 独立した機能ブロック:Header、LoginForm など
│   │   ├── Header/
│   │   └── LoginForm/
│   ├── templates/       # レイアウト定義:DashboardTemplate など
│   │   └── DashboardTemplate/
│   └── pages/           # 実データを流し込む最終画面(Next.js の場合は app/ と分離も可)
│       └── DashboardPage/
├── hooks/               # カスタムフック(useAuth、useFetch など)
├── stores/              # 状態管理(Zustand、Recoil など)
├── utils/               # ユーティリティ関数
├── types/               # 型定義
└── styles/              # グローバルスタイル・デザイントークン

各コンポーネントはディレクトリ単位で管理し、index.tsx(本体)・index.stories.tsx(Storybook)・index.test.tsx(テスト)を同じフォルダにまとめます。これにより、コンポーネントに関連するファイルを横断して探す手間がなくなり、削除・リネーム時の影響範囲も明確になります。

各層のコンポーネント実装例

Atom:Button コンポーネント

Atom は外部依存なし・props のみで動作が原則です。バリアント(見た目のバリエーション)やサイズを props で受け取り、スタイルを切り替えます。アクセシビリティのために aria-labeldisabled も適切に伝播させます。

// components/atoms/Button/index.tsx
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

type Props = {
  children: React.ReactNode;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  onClick?: () => void;
};

// variantとsizeに対応するクラス名のマッピング
const variantClass: Record = {
  primary:   'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  danger:    'bg-red-600 text-white hover:bg-red-700',
};
const sizeClass: Record = {
  sm: 'px-3 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
};

export const Button = ({
  children,
  variant = 'primary',
  size = 'md',
  disabled = false,
  onClick,
}: Props) => {
  return (
    
  );
};

Molecule:FormField コンポーネント

Molecule はAtom を組み合わせて1つの役割を担うコンポーネントです。FormField は「ラベル + 入力 + エラーメッセージ」という1セットの入力フィールドを表します。この層でも外部 API への依存は持たず、props 経由でデータを受け取ります。

// components/molecules/FormField/index.tsx
import { Input } from '@/components/atoms/Input';
import { Label } from '@/components/atoms/Label';

type Props = {
  label: string;
  name: string;
  type?: string;
  value: string;
  onChange: (e: React.ChangeEvent) => void;
  error?: string;        // バリデーションエラーがあれば表示
  required?: boolean;
};

export const FormField = ({
  label, name, type = 'text', value, onChange, error, required,
}: Props) => {
  return (
    
{error && ( )}
); };

Organism:LoginForm コンポーネント

Organism はフォーム全体の構造と状態管理を担います。バリデーションロジックや送信処理のコールバックはここで扱いますが、API 呼び出し自体は props(onSubmit)経由で Page 層から注入することで、Organism 自体のテスト容易性を保ちます。

// components/organisms/LoginForm/index.tsx
import { useState } from 'react';
import { FormField } from '@/components/molecules/FormField';
import { Button } from '@/components/atoms/Button';

type FormValues = { email: string; password: string };
type Props = {
  onSubmit: (values: FormValues) => Promise;
  isLoading?: boolean;
};

export const LoginForm = ({ onSubmit, isLoading = false }: Props) => {
  const [values, setValues] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState>({});

  const validate = (): boolean => {
    const newErrors: Partial = {};
    if (!values.email) newErrors.email = 'メールアドレスを入力してください';
    if (!values.password) newErrors.password = 'パスワードを入力してください';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    await onSubmit(values);
  };

  const handleChange = (e: React.ChangeEvent) => {
    setValues((prev) => ({ ...prev, [e.target.name]: e.target.value }));
  };

  return (
    
); };

Template:AuthTemplate

Template はレイアウトの骨格だけを担います。実際のコンテンツは children や props で受け取り、データ取得は行いません。Storybook でモックコンポーネントを流し込んでレイアウトを確認するのに最適な層です。

// components/templates/AuthTemplate/index.tsx
type Props = {
  children: React.ReactNode;
  title: string;
  description?: string;
};

export const AuthTemplate = ({ children, title, description }: Props) => {
  return (
    

{title}

{description && (

{description}

)} {children}
); };

Page:LoginPage

Page 層では Template に実データを流し込み、API 呼び出しやルーティングなどのページ固有ロジックをまとめます。Page はコンポーネントというよりも「画面の司令塔」として機能します。

// components/pages/LoginPage/index.tsx(または app/login/page.tsx)
import { useRouter } from 'next/navigation';
import { AuthTemplate } from '@/components/templates/AuthTemplate';
import { LoginForm } from '@/components/organisms/LoginForm';
import { useAuth } from '@/hooks/useAuth';

export const LoginPage = () => {
  const router = useRouter();
  const { login, isLoading } = useAuth();

  const handleSubmit = async (values: { email: string; password: string }) => {
    try {
      await login(values.email, values.password);
      router.push('/dashboard'); // ログイン成功後にリダイレクト
    } catch (error) {
      console.error('ログインに失敗しました:', error);
    }
  };

  return (
    
      
    
  );
};

Storybook との連携

Atomic Design と Storybook は相性が抜群です。各層のコンポーネントに Story を書くことで、「コンポーネントカタログ」として視覚的に確認しながら開発を進められます。特に Atom・Molecule は外部依存がないため、Story が書きやすく MSW なしでも動作します。

// components/atoms/Button/index.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '.';

const meta: Meta = {
  title: 'Atoms/Button',   // Storybook の階層が Atomic Design の層に対応
  component: Button,
  tags: ['autodocs'],       // props の型定義から自動でドキュメントを生成
};
export default meta;

type Story = StoryObj;

export const Primary: Story = {
  args: { children: '送信する', variant: 'primary' },
};
export const Danger: Story = {
  args: { children: '削除する', variant: 'danger' },
};
export const Disabled: Story = {
  args: { children: '送信できません', disabled: true },
};

Story の title'Atoms/Button' のように階層形式にすることで、Storybook のサイドバーが Atomic Design の層構造と一致し、デザイナーとエンジニアが共通の言語でコンポーネントを参照できます。

よくある設計の迷いと対策

Atomic Design を実践する中で、多くのチームが直面する迷いどころを整理します。

① 「これは Atom? Molecule?」の境界線
判断基準は「その UI を一言で表す役割名が付けられるか」です。ButtonInput はそれ以上分割できない最小単位なので Atom。SearchBar(Input + Button の組み合わせ)や FormField(Label + Input + ErrorMessage)はひとまとまりの機能名で呼べるので Molecule です。迷ったら Molecule 側に置いてリファクタリングする方が、後から分割するより低コストです。

② API 呼び出しはどの層に書くか
原則は Page 層に集約し、データを props 経由で下位の層に渡す方針が保守性を保ちやすいです。Organism 内に API 呼び出しを持たせると、コンポーネントの再利用性が下がり、テストに MSW が必須になります。カスタムフック(useAuthuseProducts など)に API ロジックを切り出し、Page 層から呼び出す設計がおすすめです。

③ 1ページにしか使わないコンポーネントはどこに置くか
「再利用されるかどうか」ではなく「責務の大きさ」で判断します。1ページにしか使わなくても、複数の Molecule を組み合わせた独立した機能ブロックなら Organism に置きます。逆に「このページの特定の場所にしか使わない小さな部品」であれば、pages/XxxPage/_components/ のようにページ専用のコンポーネントとして管理するのも選択肢です。

④ Template と Page の違いがピンとこない
Template は「モックデータを流し込んでレイアウトを確認できる状態」、Page は「実際の API データが入った状態」と考えると分かりやすいです。Storybook で Template の Story にモックデータを渡してレイアウトの崩れを確認し、Page はその Template に useQueryuseAuth の結果を流し込む、という役割分担です。

エンジニア

プロジェクト初期は Template を省略して Page に直接 Organism を並べる構成にしても問題ありません。画面数が増えてきて「このレイアウトは他のページでも使える」と感じたときに Template を切り出す、という順序の方が実務ではうまくいきやすいですね。

デザイナー

Storybook のサイドバーが Atoms / Molecules / Organisms と階層になっていると、どのコンポーネントが再利用可能かひと目で分かって、デザインレビューがしやすくなります。コンポーネントの名前と Story の title を揃えることが大事ですね。

まとめ

Atomic Design は「どこに何を置くか」の判断軸をチーム全員で共有するための共通言語です。厳格に5層すべてを使い分けることよりも、「小さく独立した部品を組み合わせて大きな画面を作る」という考え方を設計の基本に置くことが本質的な価値です。

  • Atoms:外部依存なし・props のみで動作する最小単位。Button、Input、Icon など。
  • Molecules:Atom を組み合わせ、ひとつの役割を持つ UI 単位。FormField、SearchBar など。
  • Organisms:独立した機能ブロック。バリデーション等のロジックはここまで。API 呼び出しは原則 Page 層へ。
  • Templates:実データを持たないレイアウト定義。Storybook でモックデータを流し込んで確認するのに最適。
  • Pages:API 呼び出し・認証・ルーティングを担うページの司令塔。Template に実データを流し込む。
  • Storybook との連携:Story の title を層名に揃えることで、コンポーネントカタログが Atomic Design の構造と一致する。

まずは既存のコンポーネントを Atoms・Molecules・Organisms の3層に整理することから始め、画面数が増えてきたタイミングで Templates を切り出すという段階的なアプローチが、実務では最もスムーズに定着します。

参考:サンプルプロジェクト

本記事で解説した Atomic Design の構成を実際のプロジェクトで確認できるサンプルを公開しています。

🌙 laravel-react-boilerplate

Laravel 12 + React 19 で構築した EC サイトのボイラープレートです。フロントエンドは Atomic Design に基づいたディレクトリ構成を採用しており、Storybook(http://localhost:6006/)で各層のコンポーネントをカタログとして確認できます。Vitest + Testing Library によるテストも各コンポーネントに併置されており、本記事で解説した設計方針の実装例として参考にしてみてください。

コメントを残す

入力エリアすべてが必須項目です。メールアドレスが公開されることはありません。

内容をご確認の上、送信してください。