😎

フロントエンドディレクトリ構成(仮)

2024/07/02に公開

フロントエンド(React+Vite)のディレクトリ構成を考えていた備忘録。
適宜更新予定。

ディレクトリ構成

src
├── assets/            # 画像やフォントなどの静的ファイル
├── components/        # アプリケーション全体で使用される共通コンポーネント
├── config/            # 環境変数などをエクスポートするところ
├── features/          # 機能ベースモジュール
│        └AwesomeFeature/
│           ├── api/             #エクスポートされたAPIリクエスト
│           ├── assets/          # 特定の機能で使用する静的ファイル
│           ├── components/             # 特定の機能で使用するコンポーネント (Presentational Component)
│           ├── hooks/            # 特定の機能で使用される共通フック
│           ├── routes/            # ルーティングの設定
│           ├── stores/           # 特定の機能のステートストア
│           ├── types/            # 特定の機能で使用されるTypeScriptの型
│           ├── utils/            # 特定の機能で使用されるユーティリティ関数  
│           └── index.tsx            # コンポーネントを取りまとめるモジュール(Container Component)
├── hooks/             # アプリケーション全体で使用される共通hooks
├── lib/               # ライブラリをアプリケーション用に設定して再度エクスポートしたもの
├── providers/         # アプリケーションのすべてのプロバイダー
├── routes/            # ルーティングの設定
├── stores/            # グローバルステートストア
├── test/              # テストユーティリティとモックサーバ
├── types/             # アプリケーション全体で使用される基本的な型の定義
└── utils/             # 共通のユーティリティ関数

Presentational ComponentとContainer Component

Presentational Component

見た目だけを責務とするコンポーネントのこと。

Container Component

Presentational Componentを包含してロジックを追加するコンポーネントのこと。

これらの違いは以下の通り。

Presentational Component Container Component
関心 「どのように見えるか」に関心をもつ 「どのように機能するか」に関心をもつ
内部構造 内部に DOM マークアップをふんだんに持つ DOM マークアップを可能な限り持たない
情報の流れ データや振る舞いを props として一方的に受け取る データや振る舞いを他のコンポーネントに受け渡す
状態への依存 グローバルな状態に依存しない グローバルな状態に依存することがある
状態 自身の状態を持たない(UI の状態は除く) しばしばデータの状態を持つ
データの変更 データの変更に介入しない しばしばデータの変更に介入して、任意の副作用処理を行う
表現 関数コンポーネントで表現されることが多い Hooks や render props が使われることが多い

コード例

メモを入力→入力したメモを表示するコード。なお、見た目の整形にはTailwind CSS(shadcn/ui)を用いている

Presentational Component

MemoCard.tsx
import { FC } from 'react';
import { Memos } from '../../types/memo';
import { Card, CardContent } from '@/components/ui/card';

type Props = {
  memos: Memos;
};

export const MemoCard: FC<Props> = ({ memos }) => {
  return (
    <>
      {memos.map((memo) => (
        <Card key={memo.id} className="w-56 pt-6">
          <CardContent>
            <p className="break-all text-ellipsis line-clamp-3">
              {memo.description}
            </p>
          </CardContent>
        </Card>
      ))}
    </>
  );
};
MemoInput.tsx
import { ChangeEvent, FC } from 'react';
import { Textarea } from '@/components/ui/textarea';

type Props = {
  description?: string;
  handleChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
  handleSubmit?: () => void;
};

export const MemoInput: FC<Props> = ({
  description = '',
  handleChange = () => undefined,
  handleSubmit = () => undefined,
}) => {
  return (
    <Textarea
      placeholder="メモを入力..."
      value={description}
      onChange={handleChange}
      onBlur={handleSubmit}
      className="w-96 my-4 mx-auto"
      name="MemoInput"
    />
  );
};

Container Component

index.tsx
import { FC, useState, ChangeEvent, useEffect } from 'react';
import { MemoCard } from './components/Card/MemoCard';
import { useContext } from 'react';
import { MemoContext } from '@/fetures/Memo/stores/memoContext';
import { MemoInput } from './components/Input/MemoInput';
import { postMemo } from './api/postMemo';
import { getMemo } from './api/getMemo';
import { Message } from './components/Message/Message';

export const Memo: FC = () => {
  const { memo, updateMemo } = useContext(MemoContext);
  const [description, setDescription] = useState('');

  const handleSubmit = async () => {
    postMemo(description);
    setDescription('');
    const data = await getMemo();
    updateMemo(data);
  };

  const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) =>
    setDescription(e.target.value);

  useEffect(() => {
    const fetchMemo = async () => {
      const data = await getMemo();
      updateMemo(data);
    };

    fetchMemo();
  }, [updateMemo]);

  return (
    <>
      <MemoInput {...{ description, handleChange, handleSubmit }} />
      {memo.length > 0 ? (
        <div className="flex gap-4 flex-wrap">
          {memo.map((element) => {
            return <MemoCard key={element.id} memo={element}></MemoCard>;
          })}
        </div>
      ) : (
        <Message />
      )}
    </>
  );
};

なぜ2種類のコンポーネントに分けるのか?

Componentの責務が明確になる

ロジックはContainer Component、UIはPresentational Componentといった形で責務がはっきりしているので、改修内容に応じてどのコンポーネントを修正すればいいかの判断がつきやすくなり、また影響範囲も限られるので保守性が高くなる。
たとえばUIの微調整をしたいという場合にはPresentational Componentのみを改修すれば良く、UIはそのままでロジックだけ変更したいという場合にはContainer Componentのみを改修すれば事足りる。

テストがしやすくなる

ロジックのテストであればContainer Component、UIのテストであればPresentational Componentといった具合に、それぞれのComponentに対して何をテストすれば良いかが明確になるのでテストがしやすくなる。

コンポーネントの再利用性が向上する

Presentational ComponentはPropsのみに依存しているため、定義されているPropsを渡してあげればどのComponentからも利用することができる。そのPropsのがどのようなデータを元に、どのような過程を経て渡されているかをPresentational Componentは意識する必要がない。
※ 仮にPresentational Componentが特定の状態管理ライブラリやAPIに依存している場合は使用場面が限られてしまうため、データの受け取り元がPropsのみに限られているというのが重要。

コンポーネント作成手順を遵守できる

React公式ドキュメントにて「Reactの流儀」(https://ja.react.dev/learn/thinking-in-react)というコンポーネント設計手順が紹介されている。

  1. デザインモックから始め、その UI をコンポーネントの階層構造に分解する
  2. ロジックを盛り込んでいない、静的に表現するバージョンを作成する
  3. 目的の機能を実現するために最低限必要な state を特定する
  4. state を配置し、それが適切なタイミングで更新される副作用処理を記述する
  5. 必要に応じて state のリフトアップを追加する

Presentational ComponentとContainer Componentに分けることでこの手順を遵守することができる。

参考

https://zenn.dev/sakito/articles/af87061a5016e6
https://github.com/alan2207/bulletproof-react/blob/master/docs/
https://zenn.dev/buyselltech/articles/9460c75b7cd8d1#まとめ
https://oukayuka.booth.pm/items/2367992

Discussion