Headless Component開発をはじめよう (Headless UI + React Spectrum)

公開:2020/12/18
更新:2021/02/16
6 min読了の目安(約6000字TECH技術記事

はじめに

この記事ではライブラリを活用したHeadlessなReact Component開発について紹介します。

Not Headless Component

Headless Component の紹介の前にHeadless ComponentではないComponentとはなんでしょうか。

ReactでComponent を作成する際に
Material-UIAnt Designを使ったことがある人も多いのでは多いのではないでしょうか。
これらのライブラリは<Button /><Menu />といったスタイル付属のReactコンポーネント集になっています。

自前でスタイルを書かずに使えるので便利ではあるのですが以下のような欠点があります。

細かい見た目の調整が難しい。

ライブラリにもよるのですが、細かい調整が難しいものが多いです。

例えばAnt DesignのButtonコンポーネントのAPIはこちらになります。

https://ant.design/components/button/#API

5種類の主なボタンタイプと他4つの追加プロパティを提供しています。他にもshapeなども指定できますが、どうしても細かいカスタマイズはできません。どうしてもそのライブラリ特有の印象が出てしまいます。

ライブラリのスタイルのビルドについて注意する必要がある。

ライブラリがどういった形でスタイルシートを配布しているのかによって対応が変わります。
例えばメインのアプリケーションは全てstyled-componentsでスタイルを書いているのにも関わらずライブラリが.css形式でしかスタイルを配布していない場合にビルドチェーンに手を加える必要があるかもしれません。

筆者は最近Reactアプリケーションを作る際にはNext.jsを利用することが多いのですが、公式のwith-ant-designの例などを見ると少々癖のある設定が必要なことがわかります。

Headless Component

そもそも我々はスタイルが欲しかったのでしょうか。振る舞い(DOM構造やイベント制御)が欲しかっただけでスタイルは自分で制御したいのかもしれません。
これはHeadless Componentによって解決することができます。Headless Componentとは一般的にはスタイルを持たないComponentのことを指します。

Headless UI

実際にこの思想で作られているHeadless UIを見てみましょう。

https://headlessui.dev/

Completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS.

とスタイルが無いことが最初に書かれていますね。Tailwind CSS という単語が出てきていますがこれはTailwind Labsの元で作られているからです。相性はとても良いと感じますが必ずしもTailwind CSSを一緒に使う必要はありません。

実際にコンポーネントを見てみましょう。React用のコンポーネントはpackages/@headlessui-reactにあります。説明のためTransitionに注目します。

https://github.com/tailwindlabs/headlessui/tree/develop/packages/%40headlessui-react#transition
import { Transition } from '@headlessui/react'
import { useState } from 'react'

function MyComponent() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      <Transition
        show={isOpen}
        enter="transition-opacity duration-75"
        enterFrom="opacity-0"
        enterTo="opacity-100"
        leave="transition-opacity duration-150"
        leaveFrom="opacity-100"
        leaveTo="opacity-0"
      >
        I will fade in and out
      </Transition>
    </>
  )
}

TransitionのPropsに渡しているenterleaveに注目してください。Tailwind CSSのクラス名となっています。コンポーネントに状態変化が発生した際にどのようなスタイルを付与するかコンポーネントマウント側からクラス名を指定する形をとっています。
これまでのコンポーネントはdisabledというフラグを渡したらDOMのdisabledを付与をさせるとと同時にスタイルもグレー色などに変えるものが多かったです。対してHeadless UIは見た目に関して専用のpropsを受け取るようになっています。

React Spectrum (Stately + Aria)

Adobe社が管理しているライブラリにReact Spectrum (Stately + Aria)があります。

https://react-spectrum.adobe.com/index.html

A collection of libraries and tools that help you build adaptive, accessible, and robust user experiences.

少し紛らわしいのですがReact Spectrumというプロジェクト存在しており、React Spectrum, React Stately, React Ariaの3つのライブラリが配布されています。
React Spectrum は内部でReact StatelyとReact Ariaを利用してスタイルを付与しています。

そのためHeadless Component開発という文脈ではReact Stately + React Ariaの2つを活用します。

https://react-spectrum.adobe.com/react-aria/index.html
https://react-spectrum.adobe.com/react-stately/index.html

React StatelyとReact Ariaの特徴はほぼ全てがHooksベースということです。
実際にReact Stately と React Aria を使用した例を見てみましょう。

https://react-spectrum.adobe.com/react-aria/useRadioGroup.html#example
import {useRadioGroupState} from '@react-stately/radio';

let RadioContext = React.createContext();

function RadioGroup(props) {
  let {children, label} = props;
  let state = useRadioGroupState(props);
  let {radioGroupProps, labelProps} = useRadioGroup(props, state);

  return (
    <div {...radioGroupProps}>
      <span {...labelProps}>{label}</span>
      <RadioContext.Provider value={state}>{children}</RadioContext.Provider>
    </div>
  );
}

function Radio(props) {
  let {children} = props;
  let state = React.useContext(RadioContext);
  let {inputProps} = useRadio(props, state);

  return (
    <label style={{display: 'block'}}>
      <input {...inputProps} />
      {children}
    </label>
  );
}

<RadioGroup label="Favorite pet">
  <Radio value="dogs">Dogs</Radio>
  <Radio value="cats">Cats</Radio>
</RadioGroup>

公式の例では
import {useRadioGroup, useRadio} from "@react-aria/radio";
が省略されています。

useRadioGroupStateが状態管理に使われています。
一方useRadioGroupuseRadioの返り値は対応するDOMに展開されています。
例ではdivに直接スタイルを書いていますがpropsで受け取るようにすればHeadlessなコンポーネントが作成できます。
実際に開発する際にはView層から直接これらのHooksを呼び出さず、スタイルを指定するためのPropsを受け取るコンポーネントを作成して呼び出すことが多いでしょう。

Headless UI と React Stately + Aria

2つのライブラリを見てきましたが筆者は異なる印象をうけました。

大雑把に評すると

  • React Stately + Ariaはカスタマイズが効くけど難しそう
  • Headless UIはカスタマイズが難しいけど簡単そう

React SpectrumのメンテナであるDevon Govett氏はこんなツイートをしています。

つまり自由度のためにあえて低レベルな設計にしているということですね。
render-props や component based APIを好むならReact Stately + Aria を使って自前で作ればよいということです。その逆はできません。上手くHooksを活用して機能のみに焦点を当てている設計になっています。

どっちがいいの?

適材適所。

私は業務で導入していますがHeadless UIはまだ数が少ないのでReact Stately + Ariaを使うことが多いです。この時、Headless UIの内部実装を見ながら「こうするとスタイルの指定がしやすそうだな」と参考にしながらPropsのインターフェースを決定しています。

Headless UIのほうが試しやすいでしょう。

React Stately + Aria は自由度が高いぶん最初は苦戦するかもしれません。
(大雑把ですが)DOMに渡せるものをあらゆるものを受け取ってmergePropsfilterDOMPropsというutilな関数を通すという内部実装になっています。
実装に困った時にはSpectrum Packageの実装を参考にするとよいでしょう。公式のReact Stately + Ariaの使用例といえます。

スタイルの自由度が高すぎて破綻しない?

実際の運用ではHeadlessなComponentを作って、更に取りうるスタイルを明示したラッパーComponentを作り、View層から読み込むこともありでしょう。

type Props = {
  type?: "primary" | "secondary";
};

export const Button = (props: Props) => {
  return (
    <HeadlessButton className={props.type ? `btn btn--${props.type}` : "btn"}> 
      {"FooBar"}
    </HeadlessButton>
  );
};

比較表

Headless UI React Stately + Aria
管理者 Tailwind Labs Adobe
形式 Component Hooks
リリース ベータ メジャー
ライセンス MIT Apache License 2.0
TypeScript

余談

別に新しい概念ではないのでは

はい。
Hooksの導入(だいぶ前ですが)やデザインシステムの流行りからこうした考えに改めて焦点があっているのではという考えです。

この記事に贈られたバッジ