複雑な React コンポーネントを JSX のネストで表現する
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">×</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.MenuAnchor
と Dropdown.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
課題感がわかりやすくてとても良い記事だと思いました!!👏
モーダルの例はCompositionと呼ばれている気がします。Reactの公式Docにも近いのが載っています。
ドロップダウンの例はCompound Componentsと呼ばれています。
既に解決済みでしたらすみません🙇♂️
おおお、ありがとうございます!
なんと React 公式・・・!いつでも読むべきはまず公式、基本動作でした。そして「化合物」コンポーネント、なるほど、アトミックデザインの原子や分子の言葉づかいともマッチしてていいセンスだ・・・
今更感ありますが、
react subcomponent
と検索すると同様の例がヒットするので、名称はsub-component
パターンかもしれないですね🙋♂️おおー!情報ありがとうございます。テストまで言及されてていい記事ですね。
Composiion, Compound, Sub-component と3つ候補があるので、そこまで名称統一されてない感じがわかってきました。