ヘッドレスコンポーネント:React UIを構成するためのパターン

ReactのUIコントロールが高度化するにつれて、複雑なロジックが視覚的な表現と混在することがあります。これにより、コンポーネントの動作を理解しにくくなり、テストが難しくなり、異なる外観が必要な同様のコンポーネントを構築する必要が生じます。ヘッドレスコンポーネントは、すべての非視覚的なロジックと状態管理を抽出し、コンポーネントの「脳」と「見た目」を分離します。

2023年11月7日


Photo of Juntao QIU | 邱俊涛

Juntaoは、テスト駆動開発、リファクタリング、クリーンコードに情熱を注ぐAtlassianのソフトウェアエンジニアです。彼は自分の知識を共有し、他の開発者の成長を支援することを楽しんでいます。

Juntaoはまた、この分野に関するいくつかの書籍を出版している著者でもあります。さらに、彼はブロガー、YouTuber、コンテンツクリエイターとして、人々がより良いコードを書くのを支援しています。


Reactは、UIにおけるUIコンポーネントと状態管理の考え方を革命的に変えました。しかし、新しい機能要求や機能強化が追加されるたびに、一見単純なコンポーネントは、複雑に絡み合った状態とUIロジックの集合体へと急速に進化する可能性があります。

単純なドロップダウンリストの作成を考えてみましょう。最初は簡単そうに見えます。開閉状態を管理し、その外観をデザインします。しかし、アプリケーションが成長し、進化するにつれて、このドロップダウンの要件も変化します。

これらの考慮事項はそれぞれ、ドロップダウンコンポーネントに複雑さを追加します。状態、ロジック、UIプレゼンテーションを混ぜ合わせることで、保守性が低下し、再利用性が制限されます。これらが混在するほど、意図しない副作用なしに変更を加えることが難しくなります。

ヘッドレスコンポーネントパターンの紹介

これらの課題に正面から取り組むために、ヘッドレスコンポーネントパターンは解決策を提供します。計算とUI表現の分離を重視することで、開発者は汎用的で、保守可能で、再利用可能なコンポーネントを構築することができます。

ヘッドレスコンポーネントは、Reactにおけるデザインパターンであり、通常はReactフックとして実装され、特定のUI(ユーザーインターフェース)を規定することなく、ロジックと状態管理のみに責任を持つコンポーネントです。これは操作の「脳」を提供しますが、「見た目」は実装する開発者に任せます。本質的には、特定の視覚表現を強制することなく機能を提供します。

視覚化すると、ヘッドレスコンポーネントは、一方ではJSXビューとインターフェースし、必要に応じてもう一方では基盤となるデータモデルと通信するスリムなレイヤーとして表示されます。このパターンは、UIの動作または状態管理の側面のみを必要とするユーザーにとって特に有益であり、視覚表現からこれらを便利に分離します。

図1:ヘッドレスコンポーネントパターン

たとえば、ヘッドレスドロップダウンコンポーネントを考えてみましょう。これは、開閉状態、アイテムの選択、キーボードナビゲーションなどの状態管理を処理します。レンダリングする際には、独自のハードコードされたドロップダウンUIをレンダリングする代わりに、この状態とロジックを子関数またはコンポーネントに提供し、開発者がどのように視覚的に表示されるべきかを決めることができます。

この記事では、複雑なコンポーネント(ドロップダウンリスト)をゼロから構築することで、実践的な例に掘り下げます。コンポーネントに機能を追加するにつれて、発生する課題を観察します。これを通して、ヘッドレスコンポーネントパターンがこれらの課題に対処し、異なる懸念事項を区分けし、より汎用的なコンポーネントを作成するのにどのように役立つかを示します。

ドロップダウンリストの実装

ドロップダウンリストは、多くの場所で一般的に使用されるコンポーネントです。基本的なユースケースにはネイティブのselectコンポーネントがありますが、各オプションをより細かく制御できる高度なバージョンは、より良いユーザーエクスペリエンスを提供します。

ゼロから作成する完全な実装には、一見するよりも多くの労力が必要です。キーボードナビゲーション、アクセシビリティ(たとえば、スクリーンリーダーとの互換性)、モバイルデバイスでの使いやすさなどを考慮することが重要です。

マウスクリックのみをサポートする単純なデスクトップバージョンから始め、徐々に機能を追加して現実的なものにしていきます。ここでは、本番環境で使用するためのドロップダウンリストの構築方法を教えるのではなく、いくつかのソフトウェアデザインパターンを示すことが目標です。実際には、ゼロから作成することをお勧めせず、より成熟したライブラリを使用することをお勧めします。

基本的に、ユーザーがクリックするための要素(トリガーと呼びます)と、リストパネルの表示と非表示の動作を制御するための状態が必要です。最初はパネルを非表示にし、トリガーをクリックするとリストパネルを表示します。

import { useState } from "react";

interface Item {
  icon: string;
  text: string;
  description: string;
}

type DropdownProps = {
  items: Item[];
};

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <div className="trigger" tabIndex={0} onClick={() => setIsOpen(!isOpen)}>
        <span className="selection">
          {selectedItem ? selectedItem.text : "Select an item..."}
        </span>
      </div>
      {isOpen && (
        <div className="dropdown-menu">
          {items.map((item, index) => (
            <div
              key={index}
              onClick={() => setSelectedItem(item)}
              className="item-container"
            >
              <img src={item.icon} alt={item.text} />
              <div className="details">
                <div>{item.text}</div>
                <small>{item.description}</small>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

上記のコードでは、ドロップダウンコンポーネントの基本構造を設定しました。`useState`フックを使用して、`isOpen`と`selectedItem`の状態を管理し、ドロップダウンの動作を制御します。トリガーを単純にクリックするとドロップダウンメニューが切り替わり、アイテムを選択すると`selectedItem`状態が更新されます。

コンポーネントをより小さな、管理しやすい部分に分割して、より明確に見るようにしましょう。この分解はヘッドレスコンポーネントパターンの一部ではありませんが、複雑なUIコンポーネントを部分に分割することは価値のある作業です。

ユーザーのクリックを処理する`Trigger`コンポーネントを抽出することから始められます。

const Trigger = ({
  label,
  onClick,
}: {
  label: string;
  onClick: () => void;
}) => {
  return (
    <div className="trigger" tabIndex={0} onClick={onClick}>
      <span className="selection">{label}</span>
    </div>
  );
};

`Trigger`コンポーネントは、表示する`label`と`onClick`ハンドラーを受け取る基本的なクリック可能なUI要素です。周囲のコンテキストとは無関係です。同様に、アイテムのリストをレンダリングする`DropdownMenu`コンポーネントを抽出できます。

const DropdownMenu = ({
  items,
  onItemClick,
}: {
  items: Item[];
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu">
      {items.map((item, index) => (
        <div
          key={index}
          onClick={() => onItemClick(item)}
          className="item-container"
        >
          <img src={item.icon} alt={item.text} />
          <div className="details">
            <div>{item.text}</div>
            <small>{item.description}</small>
          </div>
        </div>
      ))}
    </div>
  );
};

`DropdownMenu`コンポーネントは、それぞれアイコンと説明を持つアイテムのリストを表示します。アイテムをクリックすると、選択されたアイテムを引数として提供された`onItemClick`関数がトリガーされます。

そして、`Dropdown`コンポーネント内で`Trigger`と`DropdownMenu`を組み込み、必要な状態を提供します。このアプローチにより、`Trigger`と`DropdownMenu`コンポーネントは状態に依存せず、渡されたプロップにのみ反応します。

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <Trigger
        label={selectedItem ? selectedItem.text : "Select an item..."}
        onClick={() => setIsOpen(!isOpen)}
      />
      {isOpen && <DropdownMenu items={items} onItemClick={setSelectedItem} />}
    </div>
  );
};

この更新されたコード構造では、ドロップダウンの異なる部分に特化したコンポーネントを作成することで懸念事項を分離し、コードをより整理し、管理しやすくなりました。

図3:リストのネイティブ実装

上記のイメージに示されているように、「アイテムを選択…」トリガーをクリックしてドロップダウンを開くことができます。リストから値を選択すると、表示される値が更新され、ドロップダウンメニューが閉じられます。

この時点で、リファクタリングされたコードは明確で、各セグメントはシンプルで適応可能です。異なる`Trigger`コンポーネントを変更したり導入したりすることは比較的簡単です。しかし、より多くの機能を導入し、追加の状態を管理すると、現在のコンポーネントは機能を維持できるでしょうか?

本格的なドロップダウンリストにとって重要な機能強化であるキーボードナビゲーションで確認してみましょう。

キーボードナビゲーションの実装

ドロップダウンリストにキーボードナビゲーションを取り入れることで、マウス操作に代わる方法を提供し、ユーザーエクスペリエンスが向上します。これはアクセシビリティにとって特に重要であり、ウェブページでのシームレスなナビゲーションエクスペリエンスを提供します。`onKeyDown`イベントハンドラーを使用してこれを実現する方法を探ってみましょう。

最初は、`handleKeyDown`関数を`Dropdown`コンポーネントの`onKeyDown`イベントにアタッチします。ここでは、switch文を使用して押された特定のキーを判断し、それに応じてアクションを実行します。「Enter」キーまたは「Space」キーが押された場合、ドロップダウンが切り替わります。同様に、「ArrowDown」キーと「ArrowUp」キーを使用すると、リストアイテムを移動でき、必要に応じてリストの先頭または末尾に戻ります。

const Dropdown = ({ items }: DropdownProps) => {
  // ... previous state variables ...
  const [selectedIndex, setSelectedIndex] = useState<number>(-1);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      // ... case blocks ...
      // ... handling Enter, Space, ArrowDown and ArrowUp ...
    }
  };

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      {/* ... rest of the JSX ... */}
    </div>
  );
};

さらに、`DropdownMenu`コンポーネントが`selectedIndex`プロップを受け取るように更新しました。このプロップは、ハイライトされたCSSスタイルを適用し、現在選択されているアイテムに`aria-selected`属性を設定するために使用され、視覚的なフィードバックとアクセシビリティが向上します。

const DropdownMenu = ({
  items,
  selectedIndex,
  onItemClick,
}: {
  items: Item[];
  selectedIndex: number;
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu" role="listbox">
      {/* ... rest of the JSX ... */}
    </div>
  );
};

現在、`Dropdown`コンポーネントは状態管理コードとレンダリングロジックの両方に絡み合っています。広範なswitch文と、`selectedItem`、`selectedIndex`、`setSelectedItem`などの状態管理構造をすべて含んでいます。

カスタムフックを使用したヘッドレスコンポーネントの実装

これに対処するために、`useDropdown`というカスタムフックを介してヘッドレスコンポーネントの概念を導入します。このフックは、状態とキーボードイベント処理ロジックを効率的にラップし、重要な状態と関数を含むオブジェクトを返します。`Dropdown`コンポーネントでこれを非構造化することで、コードを整理して持続可能に保ちます。

魔法は、主人公であるヘッドレスコンポーネントである`useDropdown`フックにあります。この汎用的なユニットは、ドロップダウンに必要なすべてのものを保持します。開いているかどうか、選択されたアイテム、強調表示されたアイテム、Enterキーへの反応などです。その美しさは、その適応性です。さまざまな視覚表現(JSX要素)と組み合わせることができます。

const useDropdown = (items: Item[]) => {
  // ... state variables ...

  // helper function can return some aria attribute for UI
  const getAriaAttributes = () => ({
    role: "combobox",
    "aria-expanded": isOpen,
    "aria-activedescendant": selectedItem ? selectedItem.text : undefined,
  });

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // ... switch statement ...
  };
  
  const toggleDropdown = () => setIsOpen((isOpen) => !isOpen);

  return {
    isOpen,
    toggleDropdown,
    handleKeyDown,
    selectedItem,
    setSelectedItem,
    selectedIndex,
  };
};

これで、`Dropdown`コンポーネントは簡素化され、短くなり、理解しやすくなりました。`useDropdown`フックを利用して状態を管理し、キーボード操作を処理することで、懸念事項の明確な分離を実現し、コードの理解と管理を容易にします。

const Dropdown = ({ items }: DropdownProps) => {
  const {
    isOpen,
    selectedItem,
    selectedIndex,
    toggleDropdown,
    handleKeyDown,
    setSelectedItem,
  } = useDropdown(items);

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      <Trigger
        onClick={toggleDropdown}
        label={selectedItem ? selectedItem.text : "Select an item..."}
      />
      {isOpen && (
        <DropdownMenu
          items={items}
          onItemClick={setSelectedItem}
          selectedIndex={selectedIndex}
        />
      )}
    </div>
  );
};

これらの変更により、ドロップダウンリストにキーボードナビゲーションを正常に実装し、よりアクセスしやすく、ユーザーフレンドリーにしました。この例はまた、フックを使用して複雑な状態とロジックを構造化されたモジュール方式で管理する方法を示しており、UIコンポーネントへのさらなる機能強化と機能追加への道を開きます。

この設計の美しさは、ロジックとプレゼンテーションの明確な分離にあります。「ロジック」とは、`select`コンポーネントのコア機能を指します。開閉状態、選択されたアイテム、強調表示された要素、リストから選択するときのArrowDownキーの押下などのユーザー入力への反応です。この分割により、コンポーネントは特定の視覚表現に縛られることなく、コア動作を維持し、「ヘッドレスコンポーネント」という用語を正当化します。

ヘッドレスコンポーネントのテスト

コンポーネントのロジックは集中化されており、さまざまなシナリオで再利用できます。この機能の信頼性は非常に重要です。したがって、包括的なテストが不可欠になります。良いニュースは、このような動作のテストは簡単です。

状態管理は、公開メソッドを呼び出して対応する状態の変化を観察することで評価できます。例えば、toggleDropdownisOpen状態の関係を調べることができます。

const items = [{ text: "Apple" }, { text: "Orange" }, { text: "Banana" }];

it("should handle dropdown open/close state", () => {
  const { result } = renderHook(() => useDropdown(items));

  expect(result.current.isOpen).toBe(false);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(true);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(false);
});

キーボードナビゲーションテストは、主に視覚インターフェースがないため、やや複雑です。そのため、より統合的なテストアプローチが必要になります。効果的な方法の1つは、動作を確認するための偽のテストコンポーネントを作成することです。このようなテストは、Headlessコンポーネントの使用方法に関する説明ガイドを提供するだけでなく、JSXを使用しているため、ユーザーインタラクションに関する真の洞察を提供します。

以前の状態チェックを統合テストに置き換えた次のテストを考えてみましょう。

it("trigger to toggle", async () => {
  render(<SimpleDropdown />);

  const trigger = screen.getByRole("button");

  expect(trigger).toBeInTheDocument();

  await userEvent.click(trigger);

  const list = screen.getByRole("listbox");
  expect(list).toBeInTheDocument();

  await userEvent.click(trigger);

  expect(list).not.toBeInTheDocument();
});

以下のSimpleDropdownは、テスト専用に設計された偽の[1]コンポーネントです。Headlessコンポーネントを実装しようとするユーザー向けの実際的な例としても機能します。

const SimpleDropdown = () => {
  const {
    isOpen,
    toggleDropdown,
    selectedIndex,
    selectedItem,
    updateSelectedItem,
    getAriaAttributes,
    dropdownRef,
  } = useDropdown(items);

  return (
    <div
      tabIndex={0}
      ref={dropdownRef}
      {...getAriaAttributes()}
    >
      <button onClick={toggleDropdown}>Select</button>
      <p data-testid="selected-item">{selectedItem?.text}</p>
      {isOpen && (
        <ul role="listbox">
          {items.map((item, index) => (
            <li
              key={index}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => updateSelectedItem(item)}
            >
              {item.text}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

SimpleDropdownはテスト用に作成されたダミーコンポーネントです。useDropdownの中央集権的なロジックを使用してドロップダウンリストを作成します。「選択」ボタンをクリックすると、リストが表示または非表示になります。このリストには(Apple、Orange、Banana)の一連のアイテムが含まれており、ユーザーはクリックして任意のアイテムを選択できます。上記のテストは、この動作が意図したとおりに機能することを確認します。

SimpleDropdownコンポーネントを使用することで、より複雑だが現実的なシナリオをテストできるようになります。

it("select item using keyboard navigation", async () => {
  render(<SimpleDropdown />);

  const trigger = screen.getByRole("button");

  expect(trigger).toBeInTheDocument();

  await userEvent.click(trigger);

  const dropdown = screen.getByRole("combobox");
  dropdown.focus();

  await userEvent.type(dropdown, "{arrowdown}");
  await userEvent.type(dropdown, "{enter}");

  await expect(screen.getByTestId("selected-item")).toHaveTextContent(
    items[0].text
  );
});

このテストでは、ユーザーがキーボード入力を使用してドロップダウンからアイテムを選択できることを確認します。SimpleDropdownをレンダリングし、そのトリガーボタンをクリックした後、ドロップダウンにフォーカスが当たります。その後、テストでは、キーボードの矢印下キーを押して最初のアイテムに移動し、Enterキーを押して選択することをシミュレートします。次に、テストでは、選択されたアイテムに期待されるテキストが表示されるかどうかを確認します。

Headlessコンポーネントにカスタムフックを使用することは一般的ですが、唯一のアプローチではありません。実際、フックが登場する前は、開発者はレンダプロップスまたはHigher-Orderコンポーネントを使用してHeadlessコンポーネントを実装していました。今日では、Higher-Orderコンポーネントの人気は以前ほどではありませんが、Reactコンテキストを使用した宣言型APIは依然としてかなり好まれています。

context APIを使用した宣言的なヘッドレスコンポーネント

今回はReactコンテキストAPIを使用して、同様の結果を得るための代替の宣言型メソッドを紹介します。コンポーネントツリー内に階層を確立し、各コンポーネントを置き換え可能にすることで、効果的に機能する(キーボードナビゲーション、アクセシビリティなどに対応する)だけでなく、独自のコンポーネントをカスタマイズできる柔軟性も提供する貴重なインターフェースをユーザーに提供できます。

import { HeadlessDropdown as Dropdown } from "./HeadlessDropdown";

const HeadlessDropdownUsage = ({ items }: { items: Item[] }) => {
  return (
    <Dropdown items={items}>
      <Dropdown.Trigger as={Trigger}>Select an option</Dropdown.Trigger>
      <Dropdown.List as={CustomList}>
        {items.map((item, index) => (
          <Dropdown.Option
            index={index}
            key={index}
            item={item}
            as={CustomListItem}
          />
        ))}
      </Dropdown.List>
    </Dropdown>
  );
};

HeadlessDropdownUsageコンポーネントは、Itemの配列型のitemsプロップを受け取り、Dropdownコンポーネントを返します。Dropdown内では、CustomTriggerコンポーネントをレンダリングするDropdown.TriggerCustomListコンポーネントをレンダリングするDropdown.Listを定義し、items配列をマップして各アイテムにDropdown.Optionを作成し、CustomListItemコンポーネントをレンダリングします。

この構造により、コンポーネント間の明確な階層関係を維持しながら、ドロップダウンメニューのレンダリングと動作を柔軟かつ宣言的にカスタマイズできます。Dropdown.TriggerDropdown.ListDropdown.Optionコンポーネントは、スタイルが適用されていないデフォルトのHTML要素(それぞれボタン、ul、li)を提供することに注意してください。それぞれasプロップを受け入れるため、ユーザーは独自のスタイルと動作でコンポーネントをカスタマイズできます。

例えば、これらのカスタマイズされたコンポーネントを定義し、上記のように使用できます。

const CustomTrigger = ({ onClick, ...props }) => (
  <button className="trigger" onClick={onClick} {...props} />
);

const CustomList = ({ ...props }) => (
  <div {...props} className="dropdown-menu" />
);

const CustomListItem = ({ ...props }) => (
  <div {...props} className="item-container" />
);

図4:カスタマイズされた要素を使用した宣言型ユーザーインターフェース

実装は複雑ではありません。Dropdown(ルート要素)にコンテキストを定義し、管理する必要があるすべての状態を内部に配置し、子ノードでそのコンテキストを使用することで、状態にアクセスしたり(またはコンテキスト内のAPIを介してこれらの状態を変更したり)することができます。

type DropdownContextType<T> = {
  isOpen: boolean;
  toggleDropdown: () => void;
  selectedIndex: number;
  selectedItem: T | null;
  updateSelectedItem: (item: T) => void;
  getAriaAttributes: () => any;
  dropdownRef: RefObject<HTMLElement>;
};

function createDropdownContext<T>() {
  return createContext<DropdownContextType<T> | null>(null);
}

const DropdownContext = createDropdownContext();

export const useDropdownContext = () => {
  const context = useContext(DropdownContext);
  if (!context) {
    throw new Error("Components must be used within a <Dropdown/>");
  }
  return context;
};

このコードは、汎用的なDropdownContextType型と、この型でコンテキストを作成するcreateDropdownContext関数を定義します。DropdownContextはこの関数を使用して作成されます。useDropdownContextは、このコンテキストにアクセスするカスタムフックであり、<Dropdown/>コンポーネントの外で使用された場合にエラーをスローし、目的のコンポーネント階層内での適切な使用を保証します。

次に、コンテキストを使用するコンポーネントを定義できます。コンテキストプロバイダーから始められます。

const HeadlessDropdown = <T extends { text: string }>({
  children,
  items,
}: {
  children: React.ReactNode;
  items: T[];
}) => {
  const {
    //... all the states and state setters from the hook
  } = useDropdown(items);

  return (
    <DropdownContext.Provider
      value={{
        isOpen,
        toggleDropdown,
        selectedIndex,
        selectedItem,
        updateSelectedItem,
      }}
    >
      <div
        ref={dropdownRef as RefObject<HTMLDivElement>}
        {...getAriaAttributes()}
      >
        {children}
      </div>
    </DropdownContext.Provider>
  );
};

HeadlessDropdownコンポーネントは、childrenitemsの2つのプロップを受け取り、カスタムフックuseDropdownを使用して状態と動作を管理します。DropdownContext.Providerを介してコンテキストを提供し、子孫と状態と動作を共有します。div内で、参照を設定し、アクセシビリティのためにARIA属性を適用してから、入れ子になったコンポーネントを表示するためにchildrenをレンダリングし、構造化されカスタマイズ可能なドロップダウン機能を実現します。

前のセクションで定義したuseDropdownフックの使用方法と、これらの値をHeadlessDropdownの子に渡す方法に注意してください。その後、子コンポーネントを定義できます。

HeadlessDropdown.Trigger = function Trigger({
  as: Component = "button",
  ...props
}) {
  const { toggleDropdown } = useDropdownContext();

  return <Component tabIndex={0} onClick={toggleDropdown} {...props} />;
};

HeadlessDropdown.List = function List({
  as: Component = "ul",
  ...props
}) {
  const { isOpen } = useDropdownContext();

  return isOpen ? <Component {...props} role="listbox" tabIndex={0} /> : null;
};

HeadlessDropdown.Option = function Option({
  as: Component = "li",
  index,
  item,
  ...props
}) {
  const { updateSelectedItem, selectedIndex } = useDropdownContext();

  return (
    <Component
      role="option"
      aria-selected={index === selectedIndex}
      key={index}
      onClick={() => updateSelectedItem(item)}
      {...props}
    >
      {item.text}
    </Component>
  );
};

コンポーネントまたはHTMLタグと追加のプロパティを処理する型GenericComponentTypeを定義しました。ドロップダウンメニューの各部分をレンダリングする3つの関数HeadlessDropdown.TriggerHeadlessDropdown.ListHeadlessDropdown.Optionが定義されています。各関数は、asプロップを使用してコンポーネントのカスタムレンダリングを許可し、レンダリングされたコンポーネントに追加のプロパティを展開します。これらはすべて、useDropdownContextを介して共有状態と動作にアクセスします。

  • HeadlessDropdown.Triggerは、デフォルトでドロップダウンメニューを切り替えるボタンをレンダリングします。
  • HeadlessDropdown.Listは、ドロップダウンが開いている場合にリストコンテナをレンダリングします。
  • HeadlessDropdown.Optionは、個々のリストアイテムをレンダリングし、クリックされたときに選択されたアイテムを更新します。

これらの関数は、カスタマイズ可能でアクセシブルなドロップダウンメニュー構造をまとめて実現します。

Headlessコンポーネントをコードベースでどのように使用するかは、主にユーザーの好みによって異なります。個人的には、DOM(または仮想DOM)の相互作用が関与しないため、フックの方が好きです。共有状態ロジックとUI間の唯一の橋渡しはrefオブジェクトです。一方、コンテキストベースの実装では、ユーザーがカスタマイズしない場合にデフォルトの実装が提供されます。

次の例では、useDropdownフックを使用して、コア機能を維持しながら、別のUIに簡単に遷移する方法を示します。

新しいUI要件への適応

新しいデザインでボタンをトリガーとして使用し、ドロップダウンリストにテキストと一緒にアバターを表示する必要があるシナリオを考えてみましょう。ロジックが既にuseDropdownフックにカプセル化されているため、この新しいUIへの適応は簡単です。

以下の新しいDropdownTailwindコンポーネントでは、Tailwind CSS(Tailwind CSSは、カスタムユーザーインターフェースを迅速に構築するためのユーティリティファーストCSSフレームワークです)を使用して要素のスタイルを適用しました。構造はわずかに変更されており、ボタンがトリガーとして使用され、ドロップダウンリストの各アイテムには画像が含まれるようになりました。これらのUIの変更にもかかわらず、useDropdownフックのおかげで、コア機能はそのまま維持されています。

const DropdownTailwind = ({ items }: DropdownProps) => {
  const {
    isOpen,
    selectedItem,
    selectedIndex,
    toggleDropdown,
    handleKeyDown,
    setSelectedItem,
  } = useDropdown<Item>(items);

  return (
    <div
      className="relative"
      onClick={toggleDropdown}
      onKeyDown={handleKeyDown}
    >
      <button className="btn p-2 border ..." tabIndex={0}>
        {selectedItem ? selectedItem.text : "Select an item..."}
      </button>

      {isOpen && (
        <ul
          className="dropdown-menu ..."
          role="listbox"
        >
          {(items).map((item, index) => (
            <li
              key={index}
              role="option"
            >
            {/* ... rest of the JSX ... */}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

このレンディションでは、DropdownTailwindコンポーネントはuseDropdownフックと連携して、状態とインタラクションを管理します。この設計により、UIの変更や拡張が基盤となるロジックの再実装を必要としないため、新しい設計要件への適応が大幅に容易になります。

React Devtoolsでコードをより分かりやすく視覚化することもできます。*フック*セクションには、すべての状態がリストされています。

図5:Devtools

外部の外観に関係なく、すべてのドロップダウンリストは内部的に一貫した動作を共有し、それらはすべてuseDropdownフック(Headlessコンポーネント)にカプセル化されています。しかし、リモートからデータを取得する必要がある場合など、非同期状態など、より多くの状態を管理する必要がある場合はどうでしょうか。

追加の状態を使用した詳細な説明

ドロップダウンコンポーネントを進めていく中で、リモートデータを取り扱う際に発生するより複雑な状態を探求してみましょう。リモートソースからデータを取得するというシナリオは、さらにいくつかの状態を管理する必要性を生み出します。具体的には、読み込み、エラー、データの状態を処理する必要があります。

リモートデータフェッチングの概要

リモートサーバーからデータを読み込むには、loadingerrordataの3つの新しい状態を定義する必要があります。通常、useEffect呼び出しを使用してどのように行うかを示します。

//...
  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<Item[] | null>(null);
  const [error, setError] = useState<Error | undefined>(undefined);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);

      try {
        const response = await fetch("/api/users");

        if (!response.ok) {
          const error = await response.json();
          throw new Error(`Error: ${error.error || response.status}`);
        }

        const data = await response.json();
        setData(data);
      } catch (e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

//...

このコードは、loadingdataerrorの3つの状態変数を初期化します。コンポーネントがマウントされると、「/api/users」エンドポイントからデータを取得する非同期関数がトリガーされます。フェッチの前にloadingtrueに、その後falseに設定します。データが正常にフェッチされた場合は、data状態に格納されます。エラーが発生した場合は、キャプチャされ、error状態に格納されます。

エレガンスと再利用性を目的としたリファクタリング

フェッチロジックをコンポーネントに直接組み込むことはできますが、最もエレガントまたは再利用可能なアプローチではありません。ここでHeadlessコンポーネントの原則をさらに推し進め、ロジックと状態をUIから分離することができます。フェッチロジックを個別の関数に抽出することで、これをリファクタリングしましょう。

const fetchUsers = async () => {
  const response = await fetch("/api/users");

  if (!response.ok) {
    const error = await response.json();
    throw new Error('Something went wrong');
  }

  return await response.json();
};

これでfetchUsers関数ができたので、フェッチロジックを汎用的なフックに抽象化することができます。このフックは*フェッチ関数*を受け入れ、関連する読み込み、エラー、データの状態を管理します。

const useService = <T>(fetch: () => Promise<T>) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | undefined>(undefined);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);

      try {
        const data = await fetch();
        setData(data);
      } catch(e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [fetch]);

  return {
    loading,
    error,
    data,
  };
}

これで、useServiceフックは、アプリケーション全体でデータフェッチを行うための再利用可能なソリューションとして登場しました。これは、以下に示すように、さまざまな種類のデータのフェッチに使用できる優れた抽象化です。

// fetch products
const { loading, error, data } = useService(fetchProducts);
// or other type of resources
const { loading, error, data } = useService(fetchTickets);

このリファクタリングにより、データフェッチロジックを簡素化しただけでなく、アプリケーションのさまざまなシナリオで再利用可能にしました。これは、ドロップダウンコンポーネントの強化と、より高度な機能と最適化への取り組みを続ける上で、堅実な基盤を築きます。

ドロップダウンコンポーネントのシンプルさの維持

useServiceフックとuseDropdownフックで抽象化されたロジックのおかげで、リモートデータのフェッチを組み込んでも、Dropdownコンポーネントは複雑になりませんでした。コンポーネントコードは最も単純な形で残っており、フェッチ状態を効果的に管理し、受信したデータに基づいてコンテンツをレンダリングします。

const Dropdown = () => {
  const { data, loading, error } = useService(fetchUsers);

  const {
    toggleDropdown,
    dropdownRef,
    isOpen,
    selectedItem,
    selectedIndex,
    updateSelectedItem,
    getAriaAttributes,
  } = useDropdown<Item>(data || []);

  const renderContent = () => {
    if (loading) return <Loading />;
    if (error) return <Error />;
    if (data) {
      return (
        <DropdownMenu
          items={data}
          updateSelectedItem={updateSelectedItem}
          selectedIndex={selectedIndex}
        />
      );
    }
    return null;
  };

  return (
    <div
      className="dropdown"
      ref={dropdownRef as RefObject<HTMLDivElement>}
      {...getAriaAttributes()}
    >
      <Trigger
        onClick={toggleDropdown}
        text={selectedItem ? selectedItem.text : "Select an item..."}
      />
      {isOpen && renderContent()}
    </div>
  );
};

この更新されたDropdownコンポーネントでは、useServiceフックを使用してデータフェッチの状態を管理し、useDropdownフックを使用してドロップダウン固有の状態とインタラクションを管理します。renderContent関数は、フェッチの状態に基づいてレンダリングロジックをエレガントに処理し、読み込み中、エラー、またはデータのいずれの場合でも、正しいコンテンツが表示されるようにします。

上記の例では、Headlessコンポーネントがパーツ間の疎結合をどのように促進しているかに注目してください。この柔軟性により、さまざまな組み合わせでパーツを交換できます。共有LoadingおよびErrorコンポーネントを使用すると、デフォルトのJSXとスタイルを使用するUserDropdownや、異なるAPIエンドポイントからデータを取得するTailwindCSSを使用するProductDropdownを簡単に作成できます。

ヘッドレスコンポーネントパターンの結論

Headlessコンポーネントパターンは、JSXコードと基盤となるロジックをきれいに分離するための堅牢な方法を明らかにします。JSXを使用して宣言型のUIを構成することは自然ですが、真の課題は状態の管理にあります。これが、Headlessコンポーネントが状態管理の複雑さのすべてを担い、抽象化の新しい地平へと私たちを推進するところです。

本質的に、Headlessコンポーネントはロジックをカプセル化しますが、それ自体は何もしません。レンダリングの部分はコンシューマーに委ねられるため、UIのレンダリング方法に高い柔軟性が提供されます。このパターンは、異なる視覚表現で再利用したい複雑なロジックがある場合に非常に役立ちます。

function useDropdownLogic() {
  // ... all the dropdown logic
  return {
    // ... exposed logic
  };
}

function MyDropdown() {
  const dropdownLogic = useDropdownLogic();
  return (
    // ... render the UI using the logic from dropdownLogic
  );
}

Headlessコンポーネントには、複数のコンポーネントで共有できるロジックをカプセル化するため、再利用性の向上(DRY(Don't Repeat Yourself)原則の遵守)、ロジックとレンダリングを明確に区別することで懸念事項の明確な分離(保守可能なコードを作成するための基本的な実践)、さまざまな設計要件に対処する場合やさまざまなフレームワークを使用する場合に特に有利な、同じコアロジックを使用してさまざまなUI実装を採用できる柔軟性など、いくつかの利点があります。

ただし、慎重にアプローチすることが重要です。どのデザインパターンにもあるように、課題も伴います。慣れていない人にとっては、最初は学習曲線があり、開発が一時的に遅くなる可能性があります。さらに、慎重に適用しないと、Headlessコンポーネントによって導入された抽象化が間接性のレベルを増し、コードの可読性が低下する可能性があります。

このパターンは、他のフロントエンドライブラリやフレームワークにも適用できることに注意してください。例えば、Vue.jsではこの概念をrenderlessコンポーネントと呼びます。これは同じ原則に基づいており、開発者はロジックと状態管理を別々のコンポーネントに分割することで、ユーザーがそのコンポーネントを基にUIを構築できるようにします。

Angularや他のフレームワークでの実装や互換性については不明ですが、具体的な状況における潜在的なメリットを検討することをお勧めします。

GUIにおける根本的なパターンの再考

業界で長く活動されている方、またはデスクトップ環境でのGUIアプリケーションの経験がある方であれば、Headless Componentパターンを—おそらく別の名前で—ご存知かもしれません。MVVMにおけるView-Model、Presentation Model、その他、ご経験に応じて様々な用語があります。Martin Fowlerは数年前、包括的な記事でこれらの用語を詳しく解説し、MVC、Model-View-Presenterなど、GUIの世界で広く使用されている多くの用語を明確にしました。

Presentation Modelは、ビューの状態と動作をプレゼンテーション層内のモデルクラスに抽象化します。このモデルはドメイン層と連携し、ビューへのインターフェースを提供することで、ビューでの意思決定を最小限に抑えます…

-- Martin Fowler

それにもかかわらず、この確立されたパターンについてもう少し詳しく説明し、Reactやフロントエンドの世界でどのように動作するかを探る必要があると考えています。技術の進化に伴い、従来のGUIアプリケーションが直面していた課題の中には、もはや重要ではなくなり、必須要素がオプションとなるものもあります。

例えば、UIとロジックを分離する理由の1つは、特にヘッドレスCI/CD環境での組み合わせのテストが困難だったことです。そのため、テストプロセスを容易にするために、UIを持たないコードにできるだけ多くの部分を抽出することを目指しました。しかし、これはReactや多くの他のWebフレームワークでは大きな問題ではありません。まず、UIの動作、DOM操作などをテストするためのjsdomのような堅牢なインメモリテストメカニズムがあります。これらのテストはヘッドレスCI/CDサーバーなど、あらゆる環境で実行でき、Cypressを使用してインメモリブラウザ(ヘッドレスChromeなど)で実際のブラウザテストを容易に実行できます—これはMVC/MVPが考案された当時はデスクトップアプリケーションでは不可能でした。

MVCが直面したもう一つの大きな課題はデータ同期であり、PresenterやPresentation Modelが必要となり、基礎となるデータの変更を調整し、他のレンダリング部分に通知する必要がありました。その古典的な例を以下に示します。

図7:1つのモデルが複数のプレゼンテーションを持つ

上記の図では、3つのUIコンポーネント(テーブル、折れ線グラフ、ヒートマップ)は完全に独立していますが、すべて同じモデルデータを描画しています。テーブルからデータを変更すると、他の2つのグラフも更新されます。変更を検出し、対応するコンポーネントを更新するために変更を適用するには、イベントリスナーを手動で設定する必要があります。

しかし、一方向データフローの出現により、React(そして多くの他の最新のフレームワーク)は異なる道を歩んできました。開発者として、モデルの変更を監視する必要はもうありません。基本的な考え方は、すべての変更を全く新しいインスタンスとして扱い、すべてを最初から再レンダリングすることです。—ここで、仮想DOMと、差異化と調整のプロセスを見落として、全体のプロセスを大幅に簡素化していることに注意することが重要です—つまり、コードベース内で、モデルの変更後に他のセグメントを正確に更新するためのイベントリスナーを登録する必要がなくなりました。

要約すると、Headless Componentは確立されたUIパターンを再発明することを目的としていません。むしろ、コンポーネントベースのUIアーキテクチャ内での実装として機能します。ロジックと状態管理をビューから分離するという原則は、明確な責任を分担し、あるビューを別のビューに置き換える機会があるシナリオでは特に重要です。

コミュニティの理解

Headless Componentの概念は新しいものではなく、以前から存在していましたが、広く認識されたりプロジェクトに取り入れられたりすることはありませんでした。しかし、いくつかのライブラリがHeadless Componentパターンを採用しており、アクセシビリティが高く、適応性があり、再利用可能なコンポーネントの開発を促進しています。これらのライブラリの中には、すでにコミュニティ内で大きな支持を得ているものもあります。

  • React ARIA:包括的なReactアプリケーションを構築するためのアクセシビリティプリミティブとフックを提供するAdobeのライブラリです。キーボード操作、フォーカス管理、ARIAアノテーションを管理するためのフックのコレクションを提供し、アクセシビリティの高いUIコンポーネントの作成を容易にします。
  • Headless UI:完全にスタイルのない、完全にアクセシビリティの高いUIコンポーネントライブラリで、Tailwind CSSとの美しい統合を目的としています。独自のスタイル付きコンポーネントを構築するための基盤となる動作とアクセシビリティを提供します。
  • React Table:React向けに高速で拡張可能なテーブルとデータグリッドを構築するためのヘッドレスユーティリティです。複雑なテーブルを簡単に作成できる柔軟なフックを提供し、UI表現はユーザー自身が行います。
  • Downshift:アクセシビリティが高くカスタマイズ可能なドロップダウン、コンボボックスなどを簡単に作成するためのミニマリストライブラリです。レンダリング部分を定義しながら、すべてのロジックを処理します。

これらのライブラリは、複雑なロジックと動作をカプセル化することで、Headless Componentパターンの本質を体現しており、非常にインタラクティブでアクセシビリティの高いUIコンポーネントを簡単に作成できます。提供された例は学習の出発点として役立ちますが、現実世界のシナリオでは、これらの本番環境対応のライブラリを活用して、堅牢で、アクセシビリティが高く、カスタマイズ可能なコンポーネントを構築することをお勧めします。

このパターンは、複雑なロジックと状態の管理方法を学ぶだけでなく、Headless Componentアプローチを磨き上げて、現実世界の用途に適した堅牢で、アクセシビリティが高く、カスタマイズ可能なコンポーネントを提供する、本番環境対応のライブラリを探求するように促します。

要約

この記事では、再利用可能なUIロジックを作成する際に、しばしば見過ごされているHeadless Componentの概念について詳しく説明します。複雑なドロップダウンリストの作成を例に、簡単なドロップダウンから始め、キーボードナビゲーションや非同期データフェッチなどの機能を段階的に導入します。このアプローチは、再利用可能なロジックをHeadless Componentにシームレスに抽出する方法を示し、新しいUIを簡単にオーバーレイできることを強調しています。

実践的な例を通して、このような分離が、再利用可能で、アクセシビリティが高く、カスタマイズされたコンポーネントを構築するための道をどのように切り開くかを明らかにします。また、Headless Componentパターンを推進するReact Table、Downshift、React UseGesture、React ARIA、Headless UIなどの有名なライブラリにもスポットライトを当てています。これらのライブラリは、インタラクティブでユーザーフレンドリーなUIコンポーネントを開発するための事前構成されたソリューションを提供しています。

この詳細な解説は、UI開発プロセスにおける懸念事項の分離の重要な役割を強調し、スケーラブルで、アクセシビリティが高く、保守可能なReactアプリケーションを作成することの重要性を強調しています。


謝辞

すべての技術的な詳細を案内していただき、このサイトで記事を公開できるようにしてくださった、私のロールモデルであるMartin Fowlerに感謝します。

脚注

1: Fakeは、外部システムまたはリソースへのアクセスをカプセル化するオブジェクトです。すべての採用ロジックをコードベースに散らしたくない場合に役立ち、外部システムが変更されたときに1箇所で簡単に変更できます。

重要な改訂

2023年11月7日:最終回を公開

2023年11月1日:初回を公開