結論
コンポーネント内のコンテンツを条件分岐で外側のWrapperを動的変更したいというユースケースが出てきます。この時に役立つ動的にWrapperで囲むようなコンポーネントの紹介です。
コンポーネント
type Props = {
wrappers: Readonly<FC<{ children: ReactNode }>[]>
children: ReactNode
}
export const ApplyWrappers: FC<Props> = ({ wrappers, children }) =>
wrappers.reduceRight((acc, wrapper) => (wrapper ? wrapper({ children: acc }) : acc), children)
利用イメージ
const SomeComponent: FC<{ children: ReactNode }> = ({ children }) => <div data-order="4">{children}</div>
<ApplyWrappers
wrappers={[
({ children }) => <div data-order="1">{children}</div>,
({ children }) => <div data-order="2">{children}</div>,
({ children }) => <div data-order="3">{children}</div>,
SomeComponent,
]}
>
content
</ApplyWrappers>
出力イメージ(整形済)
<div data-order="1">
<div data-order="2">
<div data-order="3">
<div data-order="4">content</div>
</div>
</div>
</div>
GithubとStorybookも個人のコンポーネント検証リポジトリみたいな所で公開しているのでよかったら見てみてください!
解決したい問題と解決パターン
問題の発生する例
aタグやTooltip等で囲うか囲わないか、というような分岐が発生し、一部同じではあるものの共通部分が大きくなっていくと見通しや共通コードの管理が難しくなっていくと思います。
const Example: FC<{ href?: string; role: 'admin' | 'user' }> = ({ href, role }) => {
if (href) {
if (role === 'admin') {
return (
<a href="#a">
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
</a>
)
} else {
return (
<Tooltip content="help">
<a href="#b">
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
</a>
</Tooltip>
)
}
}
return (
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
)
}
中身をまとめるパターン
const Content: FC = () => (
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
);
const Example: FC<{ href?: string; role: 'admin' | 'user' }> = ({ href, role }) => {
if (href) {
if (role === 'admin') {
return (
<a href="#a">
<Content />
</a>
);
} else {
return (
<Tooltip content="help">
<a href="#b">
<Content />
</a>
</Tooltip>
);
}
}
return <Content />;
};
これでもいいのですが、やや見通しがまだ悪いような気がします。
また、外側の条件分岐がもっとシンプルだった場合にはそのためにコンテンツ部分を違うコンポーネントに切り出すのもなんだか仰々しく感じることもあります。
今回のサンプルを使うパターン
wrappersを先に定義するパターン(型を綺麗に解決するためにはpush等で追加するほうが良いのかも)
const Example: FC<{ href?: string; role: 'admin' | 'user' }> = ({ href, role }) => {
const wrappers = useMemo(() => {
const wrappers: FC<{ children: ReactNode }>[] = []
if (role === 'user') {
wrappers.push(({ children }) => <Tooltip content="help">{children}</Tooltip>)
}
if (role === 'admin' || role === 'user') {
wrappers.push(({ children }) => <a href={href}>{children}</a>)
}
return wrappers
}, [href, role])
return (
<ApplyWrappers wrappers={wrappers}>
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
</ApplyWrappers>
)
}
wrappersをそのまま書くパターン、typescriptの型解決で思ったより汚れてしまって微妙かもですが…。
const Example: FC<{ href?: string; role: 'admin' | 'user' }> = ({ href, role }) => {
return (
<ApplyWrappers
wrappers={
[
role === 'user' && (({ children }: { children: ReactNode }) => <Tooltip content="help">{children}</Tooltip>),
(role === 'admin' || role === 'user') &&
(({ children }: { children: ReactNode }) => <a href={href}>{children}</a>),
].filter(w => !!w) satisfies FC<{ children: ReactNode }>[]
}
>
<div className="inline-flex">
<div>icon</div>
<div>text</div>
<div>... other contents here</div>
</div>
</ApplyWrappers>
)
}
良い例を提供できているか段々怪しくなってきましたがいかがでしょうか。
シンプルな例
コンテンツがもっと大きい場合(カードを囲う場合等)かつ外側の動的な制御もシンプルな場合、さくっと一番外側が切り替えられて便利な気がします。
const Example: FC<{ isAdmin }> = ({ isAdmin }) => {
if (isAdmin) {
return (
<a href="#a">
<span>content</span>
</a>
)
}
return <span>content</span>
}
const Example2: FC<{ isAdmin }> = ({ isAdmin }) => {
return (
<ApplyWrappers wrappers={isAdmin ? [({ children }) => <a href="#a">{children}</a>] : []}>
<span>content</span>
</ApplyWrappers>
)
}
おわりに
検索では結構見つけづらいものの、個人的にプロジェクト毎に似たようなコンポーネントを作るなと思い紹介してみようと思いました。
思ったよりは綺麗に見えなくてやや不安ですが、個人的にささるシーンでは結構便利なコンポーネントだと思っているのでよかったら使ってみてください!配列の適用順を含めてカスタマイズの余地もあると思っています。
それではまた!
資料
今回の実装やデモは GithubとStorybookで公開しています。
また、同じリポジトリですがshadcnの個人改造版みたいなものも作っているのでよかったら覗いてみてください!