🏗️

React.js UI/Container分離 × Hooks × Composition で責務を整理してみた話

に公開
6

1 年以上前から、チームメンバーの提案で Container/Presentational パターンを現代的にアレンジした設計を導入し、徐々に改良を重ねてきました。

Hooks が普及して、関数コンポーネント内で状態管理や API 呼び出しができるようになって便利になった一方で、いくつかの困りごとがありました。

「見た目だけテストしたいのに、なんで API モックの準備が必要なんだろう...?」
「このコンポーネント、何の責務を持ってるんだっけ...?」
「新しいメンバーが入ったとき、どこを変更すればいいか分からない...」

確かにファイル数は Hooks だけの時より増えてしまいますが、責務分離により各ファイルが単純になり、特にチーム開発では「単純明快なルール」の方がうまく機能することが分かってきました。

そこで、一時期「もう古い」と言われがちだった Container/Presentational パターンを、Hooks と組み合わせて現代的に活用する設計を実践してきました。

その実践記録を共有してみます。

Container/Presentational パターンについて振り返ってみる

React を書いている方なら、Presentational/Container パターンについて聞いたことがあるかもしれません。

2015 年頃から広まったこのパターンですが、2019 年に React Hooks のコアチームメンバーである Dan Abramov が自身のブログ記事で「現在は推奨していない」と更新したことで、使われる機会が減ってきました。

彼が指摘したのは、Hooks の登場により「関数コンポーネントでも状態管理やライフサイクル処理ができるようになったため、パフォーマンス最適化以外の理由で無理に分ける必要がなくなった」ということでした。

確かにその通りで、Hooks があれば一つのコンポーネント内で多くのことができるようになりました。

よくありそうな困りごとの例

たとえば、ユーザー編集モーダルの VRT(Visual Regression Test)を追加したい場面を考えてみましょう。

// 見た目だけテストしたい
<UserEditModal isOpen={true} userId="123" />

このコンポーネントは一見シンプルに見えますが、内部では:

  • ユーザー情報の API 取得
  • フォームバリデーション
  • 更新 API 呼び出し
  • 楽観的更新

など、複数の処理が動いているとします。

そうすると、純粋に「見た目が崩れていないか」を確認したいだけなのに:

  • API サーバーのモック設定
  • 認証状態のセットアップ
  • 各種ビジネスロジックのテストデータ準備

これらの準備作業が必要になってしまいます。

Storybook を作成する場合も同様で、「ちょっとレイアウト確認したいだけなのに、なんでこんなに準備が必要なんだろう...」といった声をよく聞きます。

Hooks を使ったよくある実装例を見てみる

Hooks が便利なので、機能を一つのコンポーネントにまとめることがよくあります。例えばこんな感じです:

// ユーザー管理ページ
const UserManagementPage = () => {
  // 状態管理、データ取得、ビジネスロジックなどがここに
  const [users, setUsers] = useState([]);
  const [selectedUser, setSelectedUser] = useState(null);
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);
  // ... 省略

  // データ取得、検索フィルタリング処理など
  // ... 省略

  return (
    <div>
      <SearchBox value={searchQuery} onChange={setSearchQuery} />
      <UserList users={filteredUsers} onEditClick={handleEditClick} />
      {/* ここでさらにコンポーネントがネストされる */}
      <UserEditModal
        isOpen={isEditModalOpen}
        user={selectedUser}
        onClose={() => setIsEditModalOpen(false)}
      />
    </div>
  );
};

こういう実装はよく見かけますし、動作もします。

子コンポーネントでも同様の構造が続く

// UserEditModal内でも同様の構造
const UserEditModal = ({ isOpen, user, onClose }) => {
  // フォームの状態管理、バリデーション、API呼び出しなど
  const [formData, setFormData] = useState({});
  const [validationErrors, setValidationErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // フォーム送信処理、バリデーションなど
  // ... 省略

  return (
    <Modal isOpen={isOpen}>
      <UserForm
        initialData={user}
        onSubmit={handleSubmit}
        errors={validationErrors}
        isSubmitting={isSubmitting}
      />
    </Modal>
  );
};

UserForm 内でも同様に

// 各コンポーネントで同じような構造が繰り返される
const UserForm = ({ initialData, onSubmit, errors, isSubmitting }) => {
  // フォーム各項目の状態管理
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  // ... 省略

  // バリデーション処理など
  // ... 省略

  return <form onSubmit={handleSubmit}>{/* フォームの中身 */}</form>;
};

この構造で感じたちょっとした不便さ

このような実装でも十分動作しますが、開発していていくつか気になることがありました:

テストを書くとき

  • UI の見た目だけテストしたいのに、API モックの設定が必要
  • 子コンポーネントの動作を制御するための準備が多い

Storybook を作成するとき

  • レイアウトだけ確認したいのに、ビジネスロジックの依存関係を準備する必要

機能を再利用したいとき

  • 同じ UI で異なるビジネスロジックを使いたい場合に対応しにくい

こういったことから、「もしかして Container/Presentational パターンって、今でも使い道あるんじゃない?」と思うようになりました。

試してみた提案:UI/Container + Composition + Hooks の組み合わせ

こんな困りごとを解決するために、Container/Presentational パターンを Hooks や Composition と組み合わせることを試してみました。

4 つの要素で責務を整理するアイデア

先ほどの実装例を、4 つの要素で整理してみることを考えました:

1. UI 層(.ui.tsx):純粋な見た目とインタラクション
2. Container 層(.container.tsx):データ取得とロジック統合
3. Composition:外部からのコンポーネント合成・注入
4. Hooks(useXxx.ts):再利用可能なビジネスロジック

実際に書き換えてみた例

1. Hooks でロジックを切り出し

// useUserManagement.ts
export const useUserManagement = () => {
  const [users, setUsers] = useState([]);
  const [searchQuery, setSearchQuery] = useState("");

  // データ取得
  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  // 検索フィルタリング
  const filteredUsers = useMemo(() => {
    return users.filter(
      (user) =>
        user.name.includes(searchQuery) || user.email.includes(searchQuery)
    );
  }, [users, searchQuery]);

  return {
    users: filteredUsers,
    searchQuery,
    setSearchQuery,
  };
};

// useUserEdit.ts
export const useUserEdit = () => {
  const [selectedUser, setSelectedUser] = useState(null);
  const [isModalOpen, setIsModalOpen] = useState(false);

  const openEditModal = (user) => {
    setSelectedUser(user);
    setIsModalOpen(true);
  };

  const closeEditModal = () => {
    setSelectedUser(null);
    setIsModalOpen(false);
  };

  return {
    selectedUser,
    isModalOpen,
    openEditModal,
    closeEditModal,
  };
};

2. UI 層は純粋な見た目のみ

// UserManagementPage.ui.tsx
export const UserManagementPageUI = ({
  users,
  searchQuery,
  onSearchChange,
  onEditClick,
  editModalElement, // 👈 Composition:外部から注入
}) => {
  return (
    <div>
      <SearchBox value={searchQuery} onChange={onSearchChange} />
      <UserList users={users} onEditClick={onEditClick} />
      {editModalElement} {/* 👈 入れ子を避けて外部から注入 */}
    </div>
  );
};

// UserEditModal.ui.tsx
export const UserEditModalUI = ({
  isOpen,
  onClose,
  userFormElement, // 👈 Composition:外部から注入
}) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      {userFormElement} {/* 👈 フォーム部分も外部から注入 */}
    </Modal>
  );
};

// UserForm.ui.tsx - 完全に純粋なフォーム
export const UserFormUI = ({
  name,
  email,
  department,
  onNameChange,
  onEmailChange,
  onDepartmentChange,
  onSubmit,
  errors,
  isSubmitting,
}) => {
  return (
    <form onSubmit={onSubmit}>
      <input value={name} onChange={onNameChange} error={errors.name} />
      <input value={email} onChange={onEmailChange} error={errors.email} />
      {/* ... 省略 */}
      <button type="submit" disabled={isSubmitting}>
        更新
      </button>
    </form>
  );
};

3. Container 層でロジックを統合

// UserManagementPage.container.tsx
export const UserManagementPage = () => {
  const userManagement = useUserManagement();
  const userEdit = useUserEdit();

  return (
    <UserManagementPageUI
      users={userManagement.users}
      searchQuery={userManagement.searchQuery}
      onSearchChange={userManagement.setSearchQuery}
      onEditClick={userEdit.openEditModal}
      editModalElement={
        <UserEditModalContainer
          isOpen={userEdit.isModalOpen}
          user={userEdit.selectedUser}
          onClose={userEdit.closeEditModal}
        />
      }
    />
  );
};

// UserEditModal.container.tsx
export const UserEditModalContainer = ({ isOpen, user, onClose }) => {
  const userForm = useUserForm(user);

  return (
    <UserEditModalUI
      isOpen={isOpen}
      onClose={onClose}
      userFormElement={
        <UserFormUI
          {...userForm.formData}
          {...userForm.handlers}
          errors={userForm.errors}
          isSubmitting={userForm.isSubmitting}
        />
      }
    />
  );
};

こんな変化がありました

以前の実装

  • UI とビジネスロジックが同じコンポーネント内に混在
  • コンポーネントが深くネストされる構造
  • テストで子コンポーネントの動作を制御する必要

書き換え後

  • UI 層は純粋に props を受け取って表示するだけ
  • Composition で外部からコンポーネントを注入する構造
  • Hooks で切り出したロジックを他のコンポーネントでも使える
  • UI のテストとロジックのテストを分離できる

実践するために考えたルール

この設計を試すために、チームで考えたルールを紹介します:

ファイル種別 役割 命名例 やっていいこと やってはダメなこと
xxx.ui.tsx 見た目専用 UserForm.ui.tsx ・props の表示
・UI インタラクション
・レイアウト
useState
useEffect
・API 呼び出し
xxx.container.tsx ロジック統合 UserForm.container.tsx ・Hooks の呼び出し
・UI への props 渡し
・Composition
・直接的な DOM 操作
・スタイリング
useXxx.ts ビジネスロジック useUserForm.ts ・状態管理
・API 呼び出し
・計算処理
・JSX の return
・UI 固有の処理

※ UI 層では「見た目に集中する」けど、やってはダメなことのラインがまだ定まっていない。react-hook-form など、どこの責務かすごく悩みますね。

大切にしたポイント

1. UI 層は子の振る舞いを知らない

// 以前の実装:子の状態を気にしている
const PageUI = ({ users }) => {
  return (
    <div>
      <UserList users={users} />
      {/* UserList の内部状態に依存した処理 */}
      {users.some((u) => u.isEditing) && <div>編集中です</div>}
    </div>
  );
};

// 書き換え後:必要な情報は外部から注入
const PageUI = ({ users, isAnyUserEditing, editingIndicator }) => {
  return (
    <div>
      <UserList users={users} />
      {editingIndicator} {/* 外部から注入された要素 */}
    </div>
  );
};

2. Composition で入れ子を避ける

// 以前の実装:UI層でコンポーネントを直接入れ子
const ParentUI = () => {
  return (
    <div>
      <Child1>
        <Child2>
          <Child3 />
        </Child2>
      </Child1>
    </div>
  );
};

// 書き換え後:外部で合成されたものを受け取って配置
const ParentUI = ({ composedChildElement }) => {
  return (
    <div>
      {composedChildElement} {/* 外部で Child1>Child2>Child3 が合成済み */}
    </div>
  );
};

// Container側で合成
const ParentContainer = () => {
  const composedChild = (
    <Child1Container>
      <Child2Container>
        <Child3Container />
      </Child2Container>
    </Child1Container>
  );

  return <ParentUI composedChildElement={composedChild} />;
};

3. Container は UI とロジックの橋渡し役

// Container の責務は「UI に必要な形でデータを渡すこと」
const UserFormContainer = ({ user }) => {
  const form = useUserForm(user);
  const validation = useFormValidation(form.data);

  return (
    <UserFormUI
      {...form.data}
      {...form.handlers}
      errors={validation.errors}
      onSubmit={form.submit}
    />
  );
};

この構成で得られる効果

1. テストの分離と効率化

UI 層のテストでは、Testing Library React を使ったイベント駆動のテストが書きやすくなりました。API モックや複雑な状態管理の準備が不要で、純粋にユーザーインタラクション(fireEvent)と表示確認に集中できます。

ロジックのテストは Hooks の単体テストとして分離。renderHookを使って UI の描画確認不要で、ビジネスロジックに集中できます。

統合テストは Container 層で、UI 層とロジック層が正しく連携しているかを確認。

こうすることで、テストの実行速度も向上し、どこに問題があるか特定しやすくなりました。

2. 開発・デザインレビューの効率化

Storybook での開発も楽になりました。UI 層が純粋なので、API 接続やビジネスロジックなしでレイアウト確認が可能。エラー状態、ローディング状態なども簡単に再現できます。

3. 柔軟性と再利用性の向上

同じ UI で異なるビジネスロジックを使いたい場合に対応しやすくなりました。UI を変更せずにロジック部分だけ差し替えられるのは便利です。

4. 複雑なビジネスロジックの柔軟な構築

複数の Hooks の組み合わせにより、フォーム処理、バリデーションなどを組み合わせた複雑な処理も整理して構築できそうです。各 Hooks が独立しているため、組み合わせの柔軟性が高まります。

5. チーム開発での期待効果

責務が分離されることで、並行開発がしやすくなりそうです。UI の担当者は見た目に集中でき、ロジックの担当者はビジネスロジックに集中できるのではないかと思います。

コードレビューでも、UI の変更は見た目のみ、ロジックの変更はビジネスロジックのみに集中してレビューできるため、レビューポイントが絞りやすくなるかもしれません。

新メンバーの学習でも、ファイル命名規則が統一され、責務が明確なので、どこに何があるか分かりやすくなりそうです。

まとめ:個人的には、まだ使い道があると思っています

Container/Presentational パターンについて「もう古い」という話もありますが、個人的には、Hooks や Composition と組み合わせることで、まだまだ現役で使えるパターンだと感じています。

今回試してみた構成:

1. UI 層(.ui.tsx):純粋な見た目とインタラクション
2. Container 層(.container.tsx):データ取得とロジック統合
3. Composition:外部からのコンポーネント合成・注入
4. Hooks(useXxx.ts):再利用可能なビジネスロジック

感じたメリット

実際に試してみて感じたのは:

  • テストが書きやすくなった:UI のテストは見た目だけ、ロジックのテストは Hooks だけに集中できる
  • Storybook の作成が楽になった:ビジネスロジックの準備なしで UI を確認できる

Hooks 時代だからこそ

Hooks が便利だからこそ、ついつい一つのコンポーネントに全部詰め込みがちです。でも「できる」ことと「やるべき」ことは別かもしれません。

もちろん、全てのプロジェクトでこの構成が最適というわけではありません。チームの規模、プロジェクトの複雑さ、開発期間など、様々な要因を考慮して選択するのが良いと思います。

ただ、「テストが書きにくい」「Storybook の準備が大変」といった困りごとがある場合は、一度試してみる価値があるのではないでしょうか。

UI 層が見た目に集中できるだけで、開発体験はかなり変わります。

GitHubで編集を提案
GLOBIS Tech

Discussion

Honey32Honey32

特に↓のコードが良いですね!

C / P の分割の話になると「レイヤー分け」すること自体が目的とされがちですが、そのパターンに陥らず、キチンと「モジュール然としたモジュールへと分割する」ことが意識されていて素晴らしいと思います!

// Container の責務は「UI に必要な形でデータを渡すこと」
const UserFormContainer = ({ user }) => {
  const form = useUserForm(user);
  const validation = useFormValidation(form.data);

  return (
    <UserFormUI
      {...form.data}
      {...form.handlers}
      errors={validation.errors}
      onSubmit={form.submit}
    />
  );
};
Honey32Honey32

言及し忘れててた、

もちろん composition を使う事で、「レイヤーを意識しすぎて不自然な構造になる」ことを避けているのも素晴らしいです!

Honey32Honey32

気になった点も挙げます!

(正直、ルールとしてあまり易しくないので、現実的に適用するにはチームメンバーのスキルが求められることは仕方ないと思っていますが、理想的には)「Container 中心トップダウン」にしたほうが良いと思います。

一応こちらのスライドでまとめているので、詳しくはこちらを見ると分かりやすいかと思います。

https://speakerdeck.com/honey32/mazu-container-yorishi-meyo

  • Container を主、Presentational (= UI) を従とする
    • コンポーネントおよびファイルの名前として、Container を主として無標にして、Presentational にのみ接尾辞をつけて有標にするのが良いと思います。
      • (「有標」「無標」は、言語学用語を適当に拝借しています)
      • Container: UserEditModalContainer (UserEditModal.container.tsx)
        -> UserEditModal (UserEditModal.tsx)
      • UI: UserEditModalUI (UserEditModal.ui.tsx) のままで OK
    • なぜなら、「Container を利用するコンポーネント」から見たときに、「Container」という実装の詳細に関係する言葉が入っているのは不自然だからです。
      • それよりも、利用者は「UserEditModal というコンポーネントを利用する。(そのコンポーネントの内部では、C / P の原則にしたがった分割がなされているが、知ったこっちゃない)」という感覚でコンポーネントを利用できるほうがスッキリすると思います。
      • ついでに、import-access を使った「パッケージに基づいたアクセスの絞り込み」とも相性が良いです。無標である Container のみを public に export して、それ以外を package private にすることができて、大局的な複雑さを減らせます。
  • UI で useState, useEffect 等のフックを使っても良い。
    • たとえば、<input /> タグは、Uncontrolled Component として利用したときに、「内部にステートを持っているコンポーネント」のように振る舞いますが、これを「関心事の漏れなので NG 」とは言わないと思います。
    • Container は、あくまで、API 呼び出しや、localStorage, クエリパラメータ等の「純粋じゃないやつ」ものを押し付ける先であって、「ステートであるかそうでないか」は関係ないと思います。
    • きちんとまとめたわけではないですが、僕の記事でも実務でも、フォームのステートは 「子側のコンポーネント」に置くことが多いです。
    • このようにすると、色々とうまみがあります
      • Props の項目数が減る
        • 例えば、UserFormUI の Props は、{ defaultValues, onSubmit } のみになるはずです。
      • key を使ったステートのリセットなど、React 固有の機能を使いこなせる
        • (裏を返すと、UI でのステートを禁止すると、余計な制約が生まれそう)
Honey32Honey32

あともう一つ忘れてた、「UI で useState, useEffect 等のフックを使っても良い。」の理由ですが、

フォームにとって、「初期値が何で、どんな入力を受け付けた結果、どのような結果になるか」が本質的に重要なことなので、

value onChange に相当する大量の Props を生やして、自由にコントロール可能にする」よりも、 「defaultValues onSubmit だけを持っていて、入力については Storybook の play 関数をつかってキッチリとユーザー入力を再現する」ほうがむしろ本質的なのでは?と思っています。

堀川登喜矢堀川登喜矢

大変有難うございます!!

Container を主、Presentational (= UI) を従とする

こちら、記事ではこう紹介しましたが、Containerは実は名前を HogeContainer なら、index.ts を作って名前を Hoge で外から呼び出せるように丸めていたりしました。確かにそもそもの命名にContainer要らないから丸める必要も無いですよね。

UI で useState, useEffect 等のフックを使っても良い。

ここ、まだ悩んでいます。
社内のコードでは、見た目に関する関心であればOKとしているのですが、「じゃあ、それってどこまで?」となってちょっとぶれています。(例えば react-hook-from はどこの持ち物?など)
紹介していただいた記事読んで自分なりの考えを持ちます!

Honey32Honey32

良いですね!少しでも考える手助けになれたなら幸いです!