🪐

複雑な React コンポーネントを JSX のネストで表現する

2020/11/30に公開
4

React でアプリケーションを書くとき、ある程度使いまわせる単位でコンポーネントを作ることと思います。その使いやすさと拡張性を両立させるため、自分がよくやるパターンです。

例1)ダイアログ

ダイアログは使いまわすコンポーネントの代表格ではないでしょうか。

デザインは Bootstrap v5-alpha です。

ネストしないとき

インターフェースは大体このようになるでしょう。

<Dialog
  title="タイトル"
  onClose={() => {
    console.log("閉じる")
  }}
  buttonCancel={{
    label: "キャンセル",
    onClick: () => {
      console.log("キャンセル")
    },
  }}
  buttonSubmit={{
    label: "保存する",
    onClick: () => {
      console.log("保存する")
    },
  }}
>
  <p>中身</p>
</Dialog>

いい感じですね。問題ありません。

でも次のようなダイアログが欲しいときはどうでしょう。

title がないときはヘッダーをなくし、buttonCancel がないときはキャンセルボタンを無くしましょうか。outlined も渡せるようにして、スタイルを変化させられるようにもしましょう。

<Dialog
  buttonSubmit={{
    label: "いいよ",
    outlined: true,
    onClick: () => {
      console.log("いいってさ")
    },
  }}
>
  失敗しました
</Dialog>

Dialog の実装が早速複雑になりそうで、先が思いやられますね。

ネストして表現するとき

使う側はこう書きます。

<Dialog>
  <Dialog.Header>
    <Dialog.HeaderTitle>タイトル</Dialog.HeaderTitle>

    <Dialog.HeaderCloseButton
      onClick={() => {
        console.log("閉じる")
      }}
    />
  </Dialog.Header>

  <Dialog.Body>
    <p>中身</p>
  </Dialog.Body>

  <Dialog.Footer>
    <Dialog.FooterButton
      className="btn-secondary"
      onClick={() => {
        console.log("キャンセル")
      }}
    >
      キャンセル
    </Dialog.FooterButton>

    <Dialog.FooterButton
      className="btn-primary"
      onClick={() => {
        console.log("保存する")
      }}
    >
      保存する
    </Dialog.FooterButton>
  </Dialog.Footer>
</Dialog>

ほぼ HTML をそのまま書いたような見た目です。表示されたものとの対応がわかりやすいような気がします。タイプ数が増えそうですが、IDE が一瞬で補完してくれるので問題はありません。

実装はこのようになっています。

import React from "react"

export function Dialog({ children }: { children?: React.ReactNode }) {
  return (
    <div className="modal-dialog">
      <div className="modal-content">{children}</div>
    </div>
  )
}

Dialog.Header = function ({ children }: { children?: React.ReactNode }) {
  return <div className="modal-header">{children}</div>
}

Dialog.HeaderTitle = function ({ children }: { children?: React.ReactNode }) {
  return <h5 className="modal-title">{children}</h5>
}

Dialog.HeaderCloseButton = function ({
  ...props
}: JSX.IntrinsicElements["button"]) {
  return (
    <button type="button" className="close" aria-label="Close" {...props}>
      <span aria-hidden="true">&times;</span>
    </button>
  )
}

Dialog.Body = function ({ children }: { children?: React.ReactNode }) {
  return <div className="modal-body">{children}</div>
}

Dialog.Footer = function ({ children }: { children?: React.ReactNode }) {
  return <div className="modal-footer">{children}</div>
}

Dialog.FooterButton = function ({
  className,
  ...props
}: JSX.IntrinsicElements["button"]) {
  return (
    <button type="button" className={`btn ${className ?? ""}`} {...props} />
  )
}

使う側が「ほぼ HTML をそのまま書いたような見た目」で書いているため、実装側も大したことはしていませんね。ただ、.modal-dialog.modal-content の入れ子関係をまとめたり、補完が効かず何を指定すればいいかわからなくなりがちな className を隠蔽してくれたり、使う側としては確実に楽になっています。

ちなみに function で書いていますがアロー関数 (const Dialog = () => ...) でも大丈夫です。ただし React.FC では NG です。ただの関数には Dialog.Header のような自由なプロパティを増やすことができますが、React.FC にはそれができないからです。

const Dialog: React.FC<{ ... }> = () => { ... }

// @ts-expect-error
Dialog.Header = () => { ... }

ただの関数でいいんじゃないですかね。

ネスト表現を使って先程のシンプルなダイアログを書くと、こうなります。

<Dialog>
  <Dialog.Body>失敗しました</Dialog.Body>

  <Dialog.Footer>
    <Dialog.FooterButton
      className="btn-outline-primary"
      onClick={() => {
        console.log("いいってさ")
      }}
    >
      いいよ
    </Dialog.FooterButton>
  </Dialog.Footer>
</Dialog>

Dialog の実装には手を加えていません。やりましたね 🎉

このシンプルなダイアログをよく使うようであれば、次のようにまとめてやればよいでしょう。

import React from "react"
import { Dialog } from "./Dialog"

export function DialogConfirm({
  label = "はい",
  onConfirm,
  children,
}: {
  label?: string
  onConfirm?(): void
  children?: React.ReactNode
}) {
  return (
    <Dialog>
      <Dialog.Body>{children}</Dialog.Body>

      <Dialog.Footer>
        <Dialog.FooterButton
          className="btn-outline-primary"
          onClick={onConfirm}
        >
          {label}
        </Dialog.FooterButton>
      </Dialog.Footer>
    </Dialog>
  )
}

単機能のパーツを作っておいて、それを組み合わせたパターンをいくつか用意する、というやり方です。React っぽいというか、JSX の良さを享受している感じがします。

例2)ドロップダウンリスト

これも2通り書いてみます。

ネストしないとき

<Dropdown
  openButton={{
    onClick: toggleOpen,
    className: "btn-outline-primary",
    children: "メニュー",
  }}
  open={open}
  onAction={close}
  items={[
    {
      itemType: "header",
      children: "ヘッダー",
    },
    {
      itemType: "anchor",
      href: "#1",
      children: "リンク",
    },
    {
      itemType: "anchor",
      href: "#2",
      style: {
        paddingInlineStart: 32,
      },
      children: "インデント",
    },
    {
      itemType: "text",
      children: "ただのテキスト",
    },
    {
      itemType: "divider",
    },
    {
      itemType: "button",
      onClick: () => {
        console.log("clicked!")
      },
      children: "これはボタン",
    },
    {
      itemType: "divider",
    },
    {
      itemType: "space",
      children: "なんでも置ける",
    },
    {
      itemType: "space",
      render: (onAction) => {
        return (
          <button
            type="button"
            className="btn btn-outline-secondary"
            onClick={onAction}
          >
            なんかボタン
          </button>
        )
      },
    },
  ]}
/>

Bootstrap で可能なアイテム項目のバリエーション (header, anchor, text, divider, button) を実現しています。itemType: "space" は、なんでも置けるただの div です。

onAction は、anchor か button をクリックしたときに発生するイベントです。ドロップダウン全体に onClick を指定してもよさそうですが、それだとインタラクティブでないテキスト部分をクリックしてもイベントが発生してしまうので、避けています。

これでもいいっちゃいいんですが、UI 表現につきものな入れ子と繰り返しを表現するための JSX を使っているのに、なぜか素のオブジェクトで入れ子と繰り返しを表現することになっています。

JSX を活用していきましょう。

ネストして表現するとき

こうなります。

<Dropdown>
  <Dropdown.Button className="btn-outline-primary" onClick={toggleOpen}>
    メニュー
  </Dropdown.Button>

  <Dropdown.Menu open={open} onAction={close}>
    <Dropdown.MenuHeader>ヘッダー</Dropdown.MenuHeader>

    <a href="#1" style={{ textDecoration: "none" }}>
      <Dropdown.MenuItem>リンク</Dropdown.MenuItem>
    </a>

    <a href="#2" style={{ textDecoration: "none" }}>
      <Dropdown.MenuItem
        style={{
          paddingInlineStart: 32,
        }}
      >
        インデント
      </Dropdown.MenuItem>
    </a>

    <Dropdown.MenuText>ただのテキスト</Dropdown.MenuText>

    <Dropdown.MenuDivider />

    <Dropdown.MenuItem
      as="button"
      onClick={() => {
        console.log("clicked!")
      }}
    >
      これはボタン
    </Dropdown.MenuItem>

    <Dropdown.MenuDivider />

    <Dropdown.MenuSpace>なんでも置ける</Dropdown.MenuSpace>

    <Dropdown.MenuSpace
      render={(onAction) => {
        return (
          <button
            type="button"
            className="btn btn-outline-secondary"
            onClick={onAction}
          >
            なんかボタン
          </button>
        )
      }}
    />
  </Dropdown.Menu>
</Dropdown>

Dropdown.MenuAnchorDropdown.MenuButton はなく、代わりに Dropdown.MenuItem を a 要素で囲むか as="button" を指定するかしています。このようにしたのは、あんまり部品を増やしすぎたくないのと、a 要素が react-router の Link コンポーネントになるかもしれないからです(またはクリック率を記録するプロジェクト共通のコンポーネントかもしれません)。

このドロップダウンは拡張性十分です。メニューを開くための Dropdown.Button も、気に食わなければほかのコンポーネントにしても構いません。Dropdown.MenuSpace が自由なコンポーネントの置き場になっていますが、それすら邪魔であれば使わなくても構いません。

Dropdown の実装はこうなっています。

import React, { createContext, useContext } from "react"

export function Dropdown({ children }: { children?: React.ReactNode }) {
  return <div className="dropdown">{children}</div>
}

Dropdown.Button = function ({
  className,
  ...props
}: JSX.IntrinsicElements["button"]) {
  return (
    <button
      type="button"
      className={`dropdown-toggle btn ${className ?? ""}`}
      {...props}
    />
  )
}

const contextOnAction = createContext<(() => void) | undefined>(undefined)

Dropdown.Menu = function ({
  open,
  onAction,
  children,
}: {
  open?: boolean
  onAction?(): void
  children?: React.ReactNode
}) {
  return (
    <contextOnAction.Provider value={onAction}>
      <div className={`dropdown-menu ${open ? "show" : ""}`}>{children}</div>
    </contextOnAction.Provider>
  )
}

Dropdown.MenuItem = function ({
  as,
  active,
  disabled,
  onClick,
  ...props
}: Omit<
  React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>,
  "ref"
> & {
  as?: "button"
  active?: boolean
  disabled?: boolean
}) {
  const onAction = useContext(contextOnAction)

  switch (as) {
    case "button": {
      return (
        <button
          type="button"
          aria-current={active}
          disabled={disabled}
          className={`dropdown-item ${active ? "active" : ""} ${
            disabled ? "disabled" : ""
          }`}
          onClick={(e) => {
            if (disabled) return

            onAction?.()
            onClick?.(e)
          }}
          {...props}
        />
      )
    }

    default: {
      return (
        <span
          aria-current={active}
          aria-disabled={disabled}
          className={`dropdown-item ${active ? "active" : ""} ${
            disabled ? "disabled" : ""
          }`}
          onClick={(e) => {
            if (disabled) return

            onAction?.()
            onClick?.(e)
          }}
          {...props}
        />
      )
    }
  }
}

Dropdown.MenuText = function ({ ...props }: JSX.IntrinsicElements["span"]) {
  return <span className="dropdown-item-text" {...props} />
}

Dropdown.MenuHeader = function ({ ...props }: JSX.IntrinsicElements["h6"]) {
  return <h6 className="dropdown-header" {...props} />
}

Dropdown.MenuDivider = function () {
  return <hr className="dropdown-divider" />
}

Dropdown.MenuSpace = function ({
  render,
  children,
  ...props
}: JSX.IntrinsicElements["div"] & {
  render?(onAction?: () => void): JSX.Element
}) {
  const onAction = useContext(contextOnAction)

  return (
    <div className="px-3 py-1" {...props}>
      {render ? render(onAction) : children}
    </div>
  )
}

ダイアログよりは色々やっていそうです。とくに、onAction を渡すためにコンテキストを使っているところがトリックです。

そのほか考えたこと

  • 全部が全部この方式が適するわけでもないと思います。たとえばパンくずリスト。これはあんまりカスタマイズできなくてもいいというか、カスタマイズしすぎると別の概念になる気がします。items: { text: string; href: string }[] を props で渡せばよさそう。
  • 大きな共通ライブラリーを作るときには、Dialog.Header のように生やすとツリーシェイクが働かなくて(働かないですよね?)よくないと思います。export function DialogHeader のように一つ一つ export, import して使うしかないです。
  • 入れ子関係がわかりやすいよう、多少冗長でも Dropdown.Item のようにはしていない(Dropdown.Menu の子どもを想定していることが名前からわからなくなるので)ですが、そこら辺は好みです。Dropdown.Menu.Item でもいいかもしれませんし DropdownMenu.Item でもいいかもしれません。
  • 想定していない入れ子関係や順序も可能で、場合によっては表示が崩れるかも。ただ、たしかにその点で縛りを与えることはできませんが、柔軟性のメリットが勝つのではないでしょうか。縛りを与えたければ、シンプルなダイアログの例のようにあらかじめパターンをいくつか用意しておいて、まずそちらを使ってもらうようにする。あるいはドキュメントやカタログに様々なパターンを網羅しておく、でしょうか。
  • まさか自分が考案者なわけはないので、パターンに名前がついていそうですが、なんて呼ぶのでしょうか。

Discussion

okuparaokupara

課題感がわかりやすくてとても良い記事だと思いました!!👏

まさか自分が考案者なわけはないので、パターンに名前がついていそうですが、なんて呼ぶのでしょうか。

モーダルの例はCompositionと呼ばれている気がします。Reactの公式Docにも近いのが載っています。
https://reactjs.org/docs/composition-vs-inheritance.html

ドロップダウンの例はCompound Componentsと呼ばれています。
https://kentcdodds.com/blog/compound-components-with-react-hooks

既に解決済みでしたらすみません🙇‍♂️

kazuma1989kazuma1989

おおお、ありがとうございます!
なんと React 公式・・・!いつでも読むべきはまず公式、基本動作でした。そして「化合物」コンポーネント、なるほど、アトミックデザインの原子や分子の言葉づかいともマッチしてていいセンスだ・・・

プログラミングをするパンダプログラミングをするパンダ

今更感ありますが、react subcomponentと検索すると同様の例がヒットするので、名称はsub-componentパターンかもしれないですね🙋‍♂️

https://blog.maximeheckel.com/posts/react-sub-components-513f6679abed

kazuma1989kazuma1989

おおー!情報ありがとうございます。テストまで言及されてていい記事ですね。

Composiion, Compound, Sub-component と3つ候補があるので、そこまで名称統一されてない感じがわかってきました。