🍣

propsバケツリレー問題をコンポジションで解消し、コンポーネントの単一責務を取り戻す

2025/02/09に公開

はじめに

この記事ではReactアプリにおけるpropsバケツリレー(props drilling)問題を、解決する方法を紹介します。
ターゲットはpropsバケツリレー問題に悩んでいる・propsバケツリレー問題の何が悪いかわからない方々です。
バケツリレー問題はコンポーネントの責務が単一に保たれていないことが原因だと考えているので、コンポジション(Composition)パターンでpropsバケツリレー問題を解消すると同時にコンポーネントの責務を単一に保つことを目指します。

before・after

課題感

propsバケツリレー問題とは、親コンポーネントから子孫コンポーネントにpropsを渡す際、中間コンポーネントがpropsをただ受け取って子に渡すだけになっている状態です。

図にするとこのようになります。
Child コンポーネントが受け取ったpropsをただGrandChildに渡すだけになっています。
Reactは親から子へ一方向に流れる単方向データフローで、孫や子孫コンポーネントに直接propsを渡すことはできないためこの問題が発生しやすいです。

propsバケツリレー問題の図

propsバケツリレー問題があると、不要なレンダリングが増えてパフォーマンスが劣化したり、保守性を下げることにつながります。

また、この問題の根本的な原因はコンポーネントの単一責務の原則が考慮されていないことだと考えています。

これはコンポジションパターンを適用することで解消します。
この方法を簡単なTODOリストを例にして紹介していきます。

ケーススタディ

次のようなTODOリストを考えて、propsバケツリレー問題の原因と解消方法について考えます。

  • テーブル形式でTODOリストを表示
  • TODOリストは一つずつ削除できる
  • 選択中のTODOリストはハイライトされる

なお、簡単のためにAPIからのデータフェッチやフォームなどは考慮しません。

propsバケツリレー問題の例

propsバケツリレー問題を含んだコードサンプルです。

// TodoListPage.tsx
const TodoListPage = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: "1", name: "タスク1", status: "未完了" },
    { id: "2", name: "タスク2", status: "完了" },
  ]);
  const [selectedTodoId, setSelectedTodoId] = useState<TodoId | null>(null);

  const deleteTodo = (id: TodoId) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const selectTodo = (id: TodoId) => {
    setSelectedTodoId(id);
  };

  return (
    <div>
      <h1>Todo List Sample(Propsバケツリレー)</h1>
      <TodoListTable
        todos={todos}
        deleteTodo={deleteTodo}
        selectTodo={selectTodo}
        selectedTodoId={selectedTodoId}
      />
    </div>
  );
}
// TodoListTable.tsx
const TodoListTable = ({
  todos,
  deleteTodo,
  selectTodo,
  selectedTodoId,
}: {
  todos: Todo[];
  deleteTodo: (id: TodoId) => void;
  selectTodo: (id: TodoId) => void;
  selectedTodoId: TodoId | null;
}) => {
  return (
    <div>
      <h2>Todo List</h2>
      <table border={1} cellPadding="5" cellSpacing="0">
        <thead>
          <tr>
            <th>ID</th>
            <th>タスク名</th>
            <th>ステータス</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          {todos.map((todo) => (
            <TodoListTableRow
              key={todo.id}
              todo={todo}
              deleteTodo={deleteTodo}
              selectTodo={selectTodo}
              selectedTodoId={selectedTodoId}
            />
          ))}
        </tbody>
      </table>
    </div>
  );
}
// TodoListTableRow.tsx
const TodoListTableRow = ({
  todo,
  deleteTodo,
  selectTodo,
  selectedTodoId,
}: {
  todo: Todo;
  deleteTodo: (id: TodoId) => void;
  selectTodo: (id: TodoId) => void;
  selectedTodoId: TodoId | null;
}) => {
  const isSelected = selectedTodoId === todo.id;
  return (
    <tr style={{ backgroundColor: isSelected ? "lightgreen" : "transparent" }}>
      <td>{todo.id}</td>
      <td>{todo.name}</td>
      <td>{todo.status}</td>
      <td>
        <button onClick={() => selectTodo(todo.id)}>選択</button>
        <button onClick={() => deleteTodo(todo.id)}>削除</button>
      </td>
    </tr>
  );
}

この例では TodoListPage -> TodoListTable -> TodoListTableRow に propsバケツリレーが発生しています。

特に良くない点はTodoListTable が受け取っているpropsは、TodoListTable自体の描画や振る舞いに一切影響を与えていない所です。
これはTodoListTableが本来関心のないはずの行に表示される具体的なデータや振る舞いを知っていなければならないことを意味します。
これは単一責務違反です。

このようにpropsバケツリレー問題が発生するコンポーネントでは、単一責務の原則が無視されています。
アプリケーションの規模が大きくなるとコンポーネントのネストも深くなり、保守性やパフォーマンスの劣化を引き起こします。

コンポジションパターンで、コンポーネントの責務を単一に保つ

propsバケツリレー、もとい単一責務違反の例をコンポジションパターンで解消します。
コンポジションパターンとは、React.ReactNodeをpropsとして受け取る手法です。
このパターンを使うことで、子要素の描画内容を親コンポーネントに移譲することができます。

コンポジションパターンの代表的な例がchildrenです。
このchilrenを使ってバケツリレーを解消します。
具体的には、TodoListTableはchildrenのみ受け取り、TodoListTableRowはTodoListPageから直接呼びだされるように変更します。

// TodoListPage.tsx
const TodoListPage = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: "1", name: "タスク1", status: "未完了" },
    { id: "2", name: "タスク2", status: "完了" },
  ]);
  const [selectedTodoId, setSelectedTodoId] = useState<TodoId | null>(null);
  
  const deleteTodo = (id: TodoId) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };
  
  const selectTodo = (id: TodoId) => {
    setSelectedTodoId(id);
  };
  
  return (
    <div>
      <h1>Todo List Sample(Composition Pattern)</h1>
      <TodoListTable>
        {/* childrenにした */}
        {todos.map((todo) => (
          <TodoListTableRow
          key={todo.id}
          todo={todo}
          deleteTodo={deleteTodo}
          selectTodo={selectTodo}
          selectedTodoId={selectedTodoId}
          />
        ))}
      </TodoListTable>
    </div>
  );
}
// TodoListTable.tsx
const TodoListTable =
  // children を受け取るようにした
  ({ children }: { children: React.ReactNode }) => {
    return (
      <div>
        <h2>Todo List</h2>
        <table border={1} cellPadding="5" cellSpacing="0">
          <thead>
            <tr>
              <th>ID</th>
              <th>タスク名</th>
              <th>ステータス</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>{children}</tbody>
        </table>
      </div>
    );
  };
// TodoListTableRow.tsx
const TodoListTableRow = ({
  todo,
  deleteTodo,
  selectTodo,
  selectedTodoId,
}: {
  todo: Todo;
  deleteTodo: (id: TodoId) => void;
  selectTodo: (id: TodoId) => void;
  selectedTodoId: TodoId | null;
}) => {
  const isSelected = selectedTodoId === todo.id;
  return (
    <tr style={{ backgroundColor: isSelected ? "lightgreen" : "transparent" }}>
      <td>{todo.id}</td>
      <td>{todo.name}</td>
      <td>{todo.status}</td>
      <td>
        <button onClick={() => selectTodo(todo.id)}>選択</button>
        <button onClick={() => deleteTodo(todo.id)}>削除</button>
      </td>
    </tr>
  );
};

この変更をすることTodoListTableから行の具体的データや振る舞いに関する情報を取り除くことで、
責務をテーブルのレイアウトを決めることのみに絞ることができました。

before・after

まとめ

  • propsバケツリレー問題を発見した時は、コンポーネントの責務を見直そう
  • コンポジションパターンによって、コンポーネントの責務を単一にしよう

参考

下記資料を参考にさせていただきました

ReactにおけるProps Drilling問題を意識したコンポーネント設計
【React】Context を使う前に #2 コンポジション (ReactNode 型の Props) を使え

Discussion