【React】Headless UI の威力と実装パターン

2024/05/31に公開

はじめに

Headless UIというフロントエンドのコンポーネント設計手法について紹介します。

スタイルを持たないUIライブラリ

Radix UIやHeadless UI(ライブラリの名称)といったスタイルを持たないコンポーネントライブラリが近年脚光を浴びています。 プレゼンテーションを構成するロジックだけが備わっており、スタイルは自由自在にclassNameなどを使って注入するものです。

この手の立派なライブラリをnpm installしてもいいですが、核となるアイデアを自前で実装してみても良いかもしれません。

Headless UIの定義

「スタイルを持たない」とはよく言われますが、もう少し厳密な対象を考えてみると 「ある程度の大きさ / ある程度の状態」のどちらか一方、または両方に当てはまるUI が対象となる気がします。

極端な話、素のbutton要素も「UIのデザインを持たずロジックを持つ」と言えなくもないですが、さすがに対象外でしょう。

別の観点から言えば、ある程度複合的な要素や状態を持てばHeadless Componentになる可能性が少なからずあると言えます。

ライブラリの一覧

有名なライブラリを例えに出して、どんなものがHeadless/非Headlessかを確認してみましょう。(すべて網羅したリストではないです)

Headlessなもの

非Headlessなもの

いつ必要になるのか?

最も推奨する場合は、UIのロジックは同じなのに複数のビジュアルが存在するときです。例えば、タブUIがそれぞれ場所によりデザインが異なったり、場合によっては多階層になったりする場合を思い浮かべてみてください。

一方、1つのビジュアルしかない場合でも、内部構造がある程度複雑だと感じる場合はHeadless Componentをprivateな層として切り出すメリットがあります。

オーバーエンジニアリングだと感じたらその通り実行する価値はないと思われます。実際、7〜8程度の共通コンポーネントの開発はこれに当てはまるでしょう。

実装例

今回はタブUIを題材とし、次の技術を前提として解説します。

  • React
  • Tailwind CSS

スタイル付き

何の変哲もないシンプルなタブコンポーネントの実装です。この1つのデザインしか必要がなく、アプリの複雑度も何ら問題なさそうなら恐らく大勢が思い付くであろう最良の設計です。

タブの実装

interface TabItem {
  tab?: ReactNode;
  panel?: ReactNode;
}

interface TabsProps {
  items: TabItem[];
}

function Tabs(props: TabsProps) {
  const { items } = props;
  const tabs = items.map((item) => item.tab);
  const panels = items.map((item) => item.panel);
  const id = useId();
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <div className="border border-solid border-gray-300">
      <div role="tablist" className="bg-blue-100">
        {tabs.map((tab, index) => (
          <button
            key={index}
            role="tab"
            aria-selected={index === selectedIndex}
            id={`tabs-${id}-tab-${index}`}
            aria-controls={`tabs-${id}-panel-${index}`}
            type="button"
            onClick={() => setSelectedIndex(index)}
            className={clsx(
              'p-4',
              index === selectedIndex && 'bg-blue-600 text-white',
            )}
          >
            {tab}
          </button>
        ))}
      </div>
      {panels.map((panel, index) => (
        <div
          key={index}
          role="tabpanel"
          id={`tabs-${id}-panel-${index}`}
          className={clsx(
            'bg-gray-50 p-4',
            index !== selectedIndex && 'hidden',
          )}
        >
          {panel}
        </div>
      ))}
    </div>
  );
}

export default Tabs;

タブの利用

<Tabs
  items={[
    { tab: 'Tab1', panel: 'Panel1' },
    { tab: 'Tab2', panel: 'Pabel2' },
  ]}
/>

しかしここでデザインのパターンが増加したり、あらゆる場合を考えて柔軟に変更可能にしておきたい場合は、Headless Componentパターンによる拡張の出番です。

(a) 属性指定型

Headless Componentの実装方法には大きく分けて2通りになるのではと考えています。
そのうちハードルの低さでメリットがあるのが、この「属性指定型」です。

具体的にはスタイル付きで実装したコンポーネントにおいて、classNameを外部から受け取る形に変更して終わりです。

タブの実装

詳細
interface TabItem {
  tab?: ReactNode;
  panel?: ReactNode;
}

interface TabsProps {
  items: TabItem[];
  rootClassName?: string;
  tablistClassName?: string;
  tabClassName?: string;
  panelClassName?: string;
}

function Tabs(props: TabsProps) {
  const {
    items,
    rootClassName,
    tablistClassName,
    tabClassName,
    panelClassName,
  } = props;
  const tabs = items.map((item) => item.tab);
  const panels = items.map((item) => item.panel);
  const id = useId();
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <div className={rootClassName}>
      <div role="tablist" className={tablistClassName}>
        {tabs.map((tab, index) => (
          <button
            key={index}
            role="tab"
            aria-selected={index === selectedIndex}
            id={`tabs-${id}-tab-${index}`}
            aria-controls={`tabs-${id}-panel-${index}`}
            type="button"
            onClick={() => setSelectedIndex(index)}
            className={tabClassName}
            data-selected={index === selectedIndex}
          >
            {tab}
          </button>
        ))}
      </div>
      {panels.map((panel, index) => (
        <div
          key={index}
          role="tabpanel"
          id={`tabs-${id}-panel-${index}`}
          style={{
            ...(index !== selectedIndex ? { display: 'none' } : {}),
          }}
          className={panelClassName}
        >
          {panel}
        </div>
      ))}
    </div>
  );
}

export default Tabs;

タブの利用

<Tabs
  items={[
    { tab: 'Tab1', panel: 'Panel1' },
    { tab: 'Tab2', panel: 'Pabel2' },
  ]}
  rootClassName="border border-solid border-gray-300"
  tablistClassName="bg-blue-100"
  tabClassName="p-4 data-[selected='true']:bg-blue-600 data-[selected='true']:text-white"
  panelClassName="bg-gray-50 p-4"
/>

備考

この場合、機械的にclassNameが置き換わったものの直感的にはロジックが把握しづらく、利用者に想像を要します。
また思いがけぬ副作用として、classNameを受け入れる部分が標準と違う命名になっているためかTailwind CSSの自動補完が働きません。

上記の問題点を改善するために威力を発揮するのが、次で紹介する「合成型」の手法です。

(b) 合成型

それぞれの要素を個別に合成して使えるようにする手法です。
DOM構造を隠蔽せずに凝集度を高めることで直感的な構造を提供します。

ライブラリでもお馴染みのI/Oなので、そこで見覚えがある方もいらっしゃると思います。

詳細
interface UseTabsReturn {
  id: string;
  selectedIndex: number;
  setSelectedIndex: Dispatch<SetStateAction<number>>;
}

const TabsContext = createContext<UseTabsReturn | undefined>(undefined);

function useTabs() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('useTabs must be used within Tabs');
  }
  return context;
}

interface TabsProps extends ComponentPropsWithRef<'div'> {}

const Tabs = forwardRef<HTMLDivElement, TabsProps>((props, ref) => {
  const id = useId();
  const [selectedIndex, setSelectedIndex] = useState(0);

  return (
    <TabsContext.Provider value={{ id, selectedIndex, setSelectedIndex }}>
      <div {...props} ref={ref} />
    </TabsContext.Provider>
  );
});

Tabs.displayName = 'Tabs';

// ---

interface UseTabReturn {
  index: number;
}

const TabContext = createContext<UseTabReturn | undefined>(undefined);

function useTab() {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error('useTab must be used within TabList');
  }
  return context;
}

interface TabListProps extends Omit<ComponentPropsWithRef<'div'>, 'role'> {}

const TabList = forwardRef<HTMLDivElement, TabListProps>((props, ref) => {
  const { children, ...restProps } = props;
  return (
    <div role="tablist" {...restProps} ref={ref}>
      {Children.map(children, (child, index) => (
        <TabContext.Provider value={{ index }}>{child}</TabContext.Provider>
      ))}
    </div>
  );
});

TabList.displayName = 'TabList';

// ---

interface TabProps
  extends Omit<
    ComponentPropsWithRef<'button'>,
    'role' | 'aria-selected' | 'id' | 'aria-controls' | 'type'
  > {}

const Tab = forwardRef<HTMLButtonElement, TabProps>((props, ref) => {
  const { onClick, ...restProps } = props;
  const { id, selectedIndex, setSelectedIndex } = useTabs();
  const { index } = useTab();

  const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {
    setSelectedIndex(index);
    onClick?.(event);
  };

  return (
    <button
      role="tab"
      aria-selected={index === selectedIndex}
      id={`tabs-${id}-tab-${index}`}
      aria-controls={`tabs-${id}-panel-${index}`}
      type="button"
      onClick={handleClick}
      data-selected={index === selectedIndex}
      {...restProps}
      ref={ref}
    />
  );
});

Tab.displayName = 'Tab';

// ---

interface UseTabPanelReturn {
  index: number;
}

const TabPanelContext = createContext<UseTabPanelReturn | undefined>(undefined);

function useTabPanel() {
  const context = useContext(TabPanelContext);
  if (!context) {
    throw new Error('useTabPanel must be used within TabPanelList');
  }
  return context;
}

interface TabPanelListProps extends PropsWithChildren {}

function TabPanelList({ children }: TabPanelListProps) {
  return (
    <>
      {Children.map(children, (child, index) => (
        <TabPanelContext.Provider value={{ index }}>
          {child}
        </TabPanelContext.Provider>
      ))}
    </>
  );
}

// ---

interface TabPanelProps
  extends Omit<ComponentPropsWithRef<'div'>, 'role' | 'id'> {}

const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>((props, ref) => {
  const { style, ...restProps } = props;
  const { index } = useTabPanel();
  const { id, selectedIndex } = useTabs();

  return (
    <div
      role="tabpanel"
      id={`tabs-${id}-panel-${index}`}
      style={{
        display: index !== selectedIndex ? 'none' : style?.display,
        ...style,
      }}
      {...restProps}
      ref={ref}
    />
  );
});

TabPanel.displayName = 'TabPanel';

// ---

export { Tabs, TabList, Tab, TabPanelList, TabPanel };

タブの利用

<Tabs className="border border-solid border-gray-300">
  <TabList className="bg-blue-100">
    <Tab className="p-4 data-[selected='true']:bg-blue-600 data-[selected='true']:text-white">
      Tab1
    </Tab>
    <Tab className="p-4 data-[selected='true']:bg-blue-600 data-[selected='true']:text-white">
      Tab2
    </Tab>
  </TabList>
  <TabPanelList>
    <TabPanel className="bg-gray-50 p-4">Panel1</TabPanel>
    <TabPanel className="bg-gray-50 p-4">Panel2</TabPanel>
  </TabPanelList>
</Tabs>

実装方法まとめ

前述した2つを簡潔にまとめると次のようになります。

(a) 属性指定型 (b) 合成型
実装コスト 中〜難
自由度

なお個人的な理想は(b)です。しかし既存コンポーネントから素早く移行する・時間がない中で低コストにリファクタリングする、等の場合はひとまず(a)を選択することが理に適うことも多いです。

別の要素を描画させたいとき

先ほどまではclassNameでスタイルを注入する前提でしたが、時により既存のHTML要素またはコンポーネントをそのまま流用したいこともあります。

この有力な対処法はaspropパターンと呼ばれるパターンが定番であり、次のようなインターフェースになります。

<Tab as={<FancyButton />}>Fancy Tab</Tab>

本記事では実装方法の深堀りはしませんので、興味がある方はぜひ調べてみてください。

おわりに

Headless Componentは聞き慣れなかったりライブラリのみでしか知らなかったりする場合もありますが、案外とても身近に使えるものです。プロジェクトが複雑さを帯びてきたら、ぜひ検討してみてください。

Discussion