株式会社ヘンリーでソフトウェアエンジニアをしている小林(kobayang)です。 最近、社内のデザインシステムライブラリの更新を行った際に、汎用的なコンポーネントの実装について整理したので、その内容について記述します。
おことわり
この記事は Henry アドベントカレンダー 5 日目の記事です。 この記事は 電子カルテの開発を支える技術3 ~ モダンな技術で再発明する ~ の、「デザインシステムライブラリを実装する」から「汎用的なコンポーネントを実装するテクニック」の節を切り出した内容になります。 なお、この記事の内容は React 前提になります。
Props の定義
汎用的なコンポーネントを作る上で考慮すべき Props 定義について記述します。
HTML Attributes を公開する
HTML Attributes を UI コンポーネントから提供することで、Native の HTML と同様に UI コンポーネントを利用者が使用でき、汎用性が上がります。 たとえば、シンプルなボタンの UI コンポーネントを作ることを考えた時に、Props を次のように定義します。
type ButtonProps = { // 特定のButtonのプロパティを定義 size: ButtonSize; // HTML Attributesを定義 } & React.ButtonHTMLAttributes<HTMLButtonElement>;
onClick や onMouseEnter などのイベントハンドラ、または aria などのアクセシビリティに関するプロパティを一度に定義することができます。
forwardRef で ref を公開する
汎用的なコンポーネントを作る際は、フォーカス管理やその他さまざまな理由で ref を使いたいケースがあるため、受け渡しができるようにしておくと便利です。
React 19 以前のバージョンをサポートする際には forwardRef による ref の受け渡しが必要になります。
ref の受け渡しは次のように記述できます。
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( (props, ref) => { return <StyledButton ref={ref} {...props} />; } );
なお、React 19 から ref は forwardRef を使用しなくてもよくなったので、最新の React では直接 Props に ref を定義して良くなりました。
HTML 要素を変更可能にする
アプリ開発側がセマンティックを柔軟に指定できるようにするために、HTML 要素の設定を、UI コンポーネントから変更可能にしたいことがあります。
例としてよくあるのが、ボタンの UI コンポーネントが button 要素として使われるのか、または a 要素として使われるかを変更したい場合です。
一方で、JSX は HTML と紐づいているため、このような HTML 要素を変更可能にするためにはひと工夫が必要となります。 このような HTML 要素を変更できる UI コンポーネントのことを Polymorphic Component と呼びます。
ライブラリにおける Polymorphic Component の例
Headless UI(UI を持たない汎用的なコンポーネントライブラリ)や、スタイリングライブラリでは HTML 要素を変更するためのプロパティが定義されています。Chakra UI1、Radix UI2 などの Headless UI や styled-components3 のコンポーネントは、as プロパティによってコンポーネントの HTML 要素を切り替えることができます。Material UI4 では、component プロパティによってコンポーネントの HTML 要素を切り替えることができます。
const Button = styled.button``; // Anchor Button として使うことができる <Button as="a" href="/example_page" />;
Polymorphic Component を実装する
後述する styled-components と併用する場合のように、自前で Polymorphic Component を実装することが必要になるケースがあります。少々複雑ですが、PolymorphicComponent を次のように定義することで、自前で HTML 要素を外側から変更できるコンポーネントを実装することができます。ここでは tag というプロパティでコンポーネントの HTML 要素を変更することにします。
import * as React from "react"; type TagProps<C extends React.ElementType> = { tag?: C }; type PropsToOmit<C extends React.ElementType, P> = keyof (TagProps<C> & P); export type PolymorphicComponentProps< C extends React.ElementType, Own = object > = Own & TagProps<C> & Omit<React.ComponentPropsWithRef<C>, PropsToOmit<C, Own>>; type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"]; export type PolymorphicComponent< DefaultC extends React.ElementType, Own = object > = { <C extends React.ElementType = DefaultC>( props: PolymorphicComponentProps<C, Own> ): React.ReactElement | null; };
UI コンポーネントの実装は次のように行います。
export const Button: PolymorphicComponent<"button", ButtonProps> = ({ tag, ref, ...rest }) => { const Component = tag ?? "button"; // ここでHTML要素を変更 return <Component ref={ref as any} {...rest} />; }; // Anchor Button として使うことができる <Button tag="a" href="/example_page" />;
React 19 以前のバージョンをサポート対象とする場合は、ref を直接定義できないため、forwardRef の Helper も用意しておくと便利です。
// forwardRef の helper function forwardRefWithTag<DefaultC extends ElementType, OwnProps>( render: <C extends ElementType = DefaultC>( props: PolymorphicComponentProps<C, OwnProps>, ref: PolymorphicRef<C> ) => React.ReactElement | null ): PolymorphicComponent<DefaultC, OwnProps> { return React.forwardRef( render as unknown as any ) as unknown as PolymorphicComponent<DefaultC, OwnProps>; } export const Button = forwardRefWithTag<"button", ButtonProps>( ({ tag, ...rest }, ref) => { const Component = tag ?? "button"; return <Component ref={ref as any} {...rest} />; } );
styled-components を使用する場合の注意点
styled-components で styled を使うと、コンポーネントのスタイルを追加することができますが、as を複数回使用すると、上書きする元のスタイルが失われてしまうため注意が必要です。UI ライブラリ自身で styled-components を利用しつつ、アプリケーション側でも styled-components によってスタイルの上書きを可能にしたい場合は、as の適用を集約するようにしましょう。
Slot Pattern
Polymorphic Component を使わない別の方法として、children で指定したコンポーネントを代わりに使用するパターンもあります。Radix UI はこのパターンを Slot コンポーネントという名前で提供しており、Slot Pattern と私は呼んでいます。
Polymorphic Component で紹介した Button と同等のサンプルを記述します。
import { Slot } from "@radix-ui/react-slot"; type Props = { // true の場合、子要素のコンポーネントを使用する asChild?: boolean; // button の プロパティを定義 }; const Button = React.forwardRef<HTMLButtonElement, Props>( ({ asChild, ...rest }, ref) => { const Comp = asChild ? Slot : "button"; return <Comp ref={ref} {...rest} />; } ); // Anchor button として使うことができる <Button asChild> <a href="/example" /> </Button>;
Polymorphic Component と比較すると、Button の Props 定義がシンプルになるメリットがあります。また、コンポーネントごと分けることができるので、こちらの方が汎用的ではあります。一方で、子の要素でカスタマイズできることが暗黙的になるため、利用側のインターフェースは複雑になるデメリットがあることや、Slot は内部で cloneElement を使っており、React Server Component の互換性の問題がある5ため、使用は限定的にすると良いかもしれません。
セマンティックを自動化する
先ほど、HTML 要素を変更可能にすることでセマンティックをアプリ開発側で柔軟に設定できる方法について記述しました。一方で、ライブラリの方針次第ではセマンティックをコンポーネントの利用側で考えさせないようにする方向も考えることができます。
具体的な例として、h1-h6 の要素の自動化について考えてみます。
h1-h6 の自動化
Heading のセマンティックについて考えると、h1-h6 のレベルを使い分けることは重要です。なお、この項は 「React で h1-h6 を正しく使い分ける」6の記事を参考に記述しています。
Heading のレベルが何かは、実装するコンポーネントだけでなく、外側の HTML の構造に依存します。Context API を使うことで、h1-h6 の使い分けを行うことができます。
const HeadingLevelContext = React.createContext({ level: 1 }); export const useLevel = () => { const context = useContext(HeadingLevelContext); return context.level; }; const Section = () => { const level = useLevel(); const nextLevel = Math.min(level + 1, 6); return ( <HeadingLevelContext.Provider value={{ level: nextLevel }}> {children} </HeadingLevelContext.Provider> ); }; const Heading = ({ children }) => { const level = useLevel(); const H = `h${level}`; return <H>{children}</H>; };
上記のように Section と Heading コンポーネントを定義することで、Heading のレベル制御の使い分けが可能になります。次のように定義することで、タイトルを h1 として、サブタイトルを h2 として描画できます。
<Section> <Heading>タイトル</Heading> <Section> <Heading>サブタイトル</Heading> </Section> </Section>
機能を提供するデザインパターン
UI コンポーネントから何かしらの機能を提供する場合に、いくつかのデザインパターンを用いることがあります。代表的なパターンとして、 Render Props Pattern、Getter Props Pattern、Compound Pattern を紹介します。
Render Props Pattern
子要素の中身に依存せずに、子要素に機能を与えることができます。例として、ホバー状態を管理するコンポーネント Hover を考えてみましょう。
Render Props Pattern を用いることで、次のような使い方でホバー管理ができます。
<Hover> {({ hovered, getProps }) => ( <div {...getProps()}>{hovered ? "Hovered!" : "Hover me"}</div> )} </Hover>
実装としては次のようになります。
type Props = { hovered: boolean; getProps: () => React.HTMLAttributes<HTMLElement>; }; const Hover: React.FC<{ children: (props: Props) => ReactNode }> = ({ children, }) => { const [hovered, setHovered] = useState(false); const getProps = () => ({ onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), }); return <>{children({ hovered, getProps })}</>; };
型定義が、children: ReactNode ではなく、children: (props: Props) => ReactNode となっていることがポイントです。Render Props Pattern は hooks で記述する機能を隠蔽することができるメリットがある一方、render の実装部分が複雑になるデメリットがあります。
私の体感では、最近のライブラリは hooks 自体を提供することが多い印象があります。
Getter Props Pattern
ライブラリのインターフェースで、UI コンポーネントに aria や event handler の機能を隠蔽しつつ提供する方法として Getter Props Pattern がよく使用されます。
たとえば、SelectBox のアクセシビリティを提供するライブラリである Downshift7 の useSelect という hooks を例に出します。この hooks は Native の Select 要素ではなく、自前で SelectBox を実装する際に必要なメニュー表示であったり、開閉管理やキーボード操作などのさまざまな機能を提供します。
実装の全貌は省略しますが、useSelect の返り値には getXXXProps というプロパティがあり、これらを UI コンポーネントに差し込むことで、機能が提供されます。
const { getToggleButtonProps, ... } = useSelect(...); return <button {...getToggleButtonProps()} />;
getToggleButtonProps は、button をクリックした際の選択メニューの開閉機能であったり、aria-expanded などの aria 属性などを自動的に提供します。このような get props をコンポーネントに差し込んで機能を提供するパターンを Getter Props Pattern と呼びます。
Compound Pattern
Render Props や、Getter Props のデザインパターンは、プロパティが render 実装に露出する構造になっています。一方で React Context を使うことで、さらにそれらを隠蔽して見かけ上シンプルに機能を提供することができます。
次の例のように、<UI.Feature /> のような形で提供するデザインパターンで、Context をコンポーネントに隠蔽しつつ、機能を提供します。例として、トグルの状態を管理するコンポーネント Toggle を考えてみましょう。トグルの状態で、表示する内容の出し分けを行い、またボタンでトグルを行います。
<Toggle> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <Toggle.Button>Toggle</Toggle.Button> </Toggle>
実装としては次のように記述できます。
const ToggleContext = createContext(); function Toggle({ children }) { const [on, setOn] = useState(false); const toggle = () => setOn(!on); return ( <ToggleContext.Provider value={{ on, toggle }}> {children} </ToggleContext.Provider> ); } Toggle.On = function ToggleOn({ children }) { const { on } = useContext(ToggleContext); return on ? children : null; }; Toggle.Off = function ToggleOff({ children }) { const { on } = useContext(ToggleContext); return on ? null : children; }; Toggle.Button = function ToggleButton(props) { const { on, toggle } = useContext(ToggleContext); return <button onClick={toggle} {...props} />; };
このような Context を使って複数のコンポーネント群を提供する実装パターンを Compound Pattern と呼びます。Context を使っている分、UI コンポーネントの実装は複雑になっており、一方で、利用側は内部実装のことをほとんど考えなくてよいというメリットがあります。
どのデザインパターンを採用するか?
3 つの代表的なデザインパターンを紹介しました。
体感として、Headless UI では Compound Pattern が内部実装を隠蔽できることから、多く採用されていることが多い印象があります。一方で、Render Props Pattern や、そもそも hooks を提供する方がライブラリの実装の見通しがよいため、そちらを採用するという視点もあるでしょう。
どのパターンにも一長一短があり、ライブラリの課題感や提供する規模感などに応じて、適切にパターンを採用することが重要です。
コンポーネントにスタイルプロパティを提供する
Headless UI などのライブラリでは、スタイリング機能を提供するプロパティをコンポーネント自体に追加していることがあります。Chakra UI、Radix UI、Material UI では Box という名前で、スタイリングを行うベースコンポーネントを提供しています。
Box
Chakra UI を例にとると、次のように Box を使うことができます。
<Box bg="tomato" w="100%" p="4" color="white" _hover={{ bg: "green" }}> This is the Box </Box>
Chakra UI は _hover でホバーリアクションなど、擬似クラスも定義できることが特徴です。一方で、擬似クラスが提供されている場合は、スタイリング用のライブラリにも依存しているため、既存で使用しているスタイリング用のライブラリとの衝突が起きないか注意が必要です。
styled-system による Box の定義
styled-components を使用している場合、styled-system8 というライブラリを用いて、Box を自前で定義できます。
簡単に提供するなら、次のように記述できます。
import styled from "styled-components"; import { space, layout, typography, color } from "styled-system"; const Box = styled.div` ${space} ${layout} ${typography} ${color} `;
自分で定義をカスタマイズして実装することもできます。自前で CSS プロパティを定義する Box の実装例を次に挙げます。
const StyledBox = styled("div").withConfig({ shouldForwardProp: (prop) => { return !stylePropNames.has(prop); }, })<StyleProps>` box-sizing: border-box; min-width: 0; ${styledSystemConfig}; `; export const Box = forwardRefWithTag<"div", StyleProps>( ({ tag = "div" as ElementType, ...rest }, ref) => { return <StyledBox as={tag} ref={ref} {...rest} />; } );
スタイル提供用の設定は次のように記述できます。
import { CSSProperties } from "react"; import { compose, Config, system } from "styled-system"; const paddingConfig = { p: { property: "padding" }, pt: { property: "paddingTop" }, pb: { property: "paddingBottom" }, pl: { property: "paddingLeft" }, pr: { property: "paddingRight" }, px: { properties: ["paddingLeft", "paddingRight"] }, py: { properties: ["paddingTop", "paddingBottom"] }, } as const satisfies Config; const padding = system(paddingConfig); const stylePropConfig = { ...paddingConfig, // 他に必要なスタイル定義を登録する } as const; export const styledSystemConfig = compose( padding // 他に必要なスタイル定義を登録する ); export const stylePropNames = new Set(styledSystemConfig.propNames ?? []); type StyleConfig = typeof stylePropConfig; type PrimaryCssPropName<Config> = Config extends { property: infer Property } ? Property & keyof CSSProperties : Config extends { properties: readonly [infer First, ...unknown[]] } ? First & keyof CSSProperties : never; type CssPropertyValue<Config> = PrimaryCssPropName<Config> extends [never] ? CSSProperties[keyof CSSProperties] : CSSProperties[PrimaryCssPropName<Config>]; export type StyleProps = { [Prop in keyof StyleConfig]?: CssPropertyValue<StyleConfig[Prop]>; };
このように記述することで、Box へのスタイルを型付きで組み込むことができるようになります。
既存ライブラリに実装を移譲する
ユーザビリティやアクセシビリティを考慮したコンポーネントを自前で実装するのはとても難しいです。
たとえば、ボタンなどを押下したときに表示される Popup パネル(Popover)を実装することを考えると、次のような機能を満たす必要があります。
- フォーカス管理
- 開いたら最初のフォーカス可能要素をフォーカス
- 閉じたら元のトリガーにフォーカスを戻す
- キーボード操作
- Esc で閉じる
- パネル内の Tab キーによる移動
- 矢印キーによる選択項目の移動
- 開閉管理
- 外側クリックで閉じる
- 位置決め・衝突管理
- 決められた位置に表示する
- ビューポートの大きさによって上下や左右の位置を入れ替える
また、さらに role や aria-expanded などのアクセシビリティ属性、アニメーションなど、プロダクトの性質やユーザー属性によって追加で対応が必要になります。
自前で上記の機能に対応するのは割に合わないことが多く、特にこだわりがなければ既存のライブラリを使うことをオススメします。ここまでに紹介した Radix UI などの Headless UI ライブラリがその一例で、一般的なユーザビリティやアクセシビリティを担保することができます。
Floating UI を用いた Popover 実装
ここでは、Floating UI9 というライブラリを用いたコンポーネントの実装を紹介します。
ライブラリ名のように、ボタン押下で浮かび上がる Popup や Tooltip などのコンポーネントのユーティリティであることはもちろんですが、それに加えて、Menu や SelectBox、ダイアログなど、何かしらのトリガーアイテムによって表示される要素全般に対して機能を提供しています。
Floating UI を使った Popover の実装を考えます。次のような hooks を提供し、Popover のコンポーネントに Props を当てることができます。
function usePopover<ReferenceElm extends HTMLElement>({ placement, offsetPx, }: PopoverArgs): PopoverProps<ReferenceElm> { const [open, setOpen] = useState(false); // For Floating UI Positioning const { refs, floatingStyles, context } = useFloating({ open, onOpenChange: setOpen, placement: placement, whileElementsMounted: autoUpdate, middleware: [ offset({ mainAxis: offsetPx }), flip({ padding: FlipPadding }), shift({ padding: ShiftPadding }), ], }); // For Interactions const { getReferenceProps, getFloatingProps } = useInteractions([ // Dismiss useDismiss(context, { escapeKey: true, outsidePress: true, bubbles: true, }), // Role useRole(context, { role: "dialog", }), ]); return { open, setOpen, referenceRef: refs.setReference, getReferenceProps, floatingRef: refs.setFloating, getFloatingProps, floatingStyles, context, }; }
内容は割愛しますが、referenceRef と Getter Props である getReferenceProps をトリガーとなるボタンに、floatingRef と getFloatingProps を Popup したいコンテンツに差し込むことで、Popover 機能を実現することができます。詳しくは Floating UI のドキュメントを参照していただけると嬉しいです。
まとめ
汎用的な UI コンポーネントを作る上でのテクニックについて記述しました。
複雑に思える既存のライブラリの実装も、分解すると上記のテクニックやパターンの組み合わせであったりします。たとえば、Radix UI では、Compound Pattern と Polymorphic/Slot Pattern を組み合わせることで、高い汎用性と機能の凝集性を持ったコンポーネントの提供を実現しています。
この内容が、デザインシステムライブラリのUI実装などの参考になれば幸いです!
- https://chakra-ui.com↩
- https://www.radix-ui.com↩
- https://www.styled-components.com↩
- https://mui.com↩
- https://zenn.dev/tsuboi/articles/8abddb1ae3038f↩
- https://zenn.dev/neet/articles/f25abb616ec105↩
- https://www.downshift-js.com↩
- https://github.com/styled-system/styled-system↩
- https://floating-ui.com↩