ReactでVue.jsのslot的なことをする
始めに
Reactは子要素にはchildren
というpropsに入るため、基本的には一つの場所にしか表示することしかできません。しかしVue.jsにはslotという機能があって、以下のようにnameを設定することでそれぞれに表示したい場所を指定することができます。
// Layout.vue
<template>
<div>
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>
</div>
</template>
<template>
<Layout>
<template v-slot:header>ヘッダー</template>
<template v-slot:body>コンテンツ</template>
<template v-slot:footer>フッター</template>
</Layout>
</template>
<script lang="ts">
import Layout from './Layout.vue'
export default {
components: {
Layout
}
}
</script>
これはVue.jsならではの機能ではありますが、Reactもやろうと思えばそれに近しい表現はできるので、そのやり方についてまとめてみました。
renderPropsで表現
Reactのchildren
は子要素の中に書いていますが、最終的にはpropsとして入ります。したがってchildren
以外のpropsでも子要素としてrenderすることができ、それぞれのrender用のpropsを定義することでrender場所を指定することができるようになります。
import { FC, ReactChild } from "react";
type Props = {
renderHeader: () => ReactChild;
renderBody: () => ReactChild;
renderFooter: () => ReactChild;
};
export const LayoutByRenderProps: FC<Props> = (props) => {
return (
<div>
<header>{props.renderHeader()}</header>
<div>{props.renderBody()}</div>
<footer>{props.renderFooter()}</footer>
</div>
);
};
import { FC } from 'react';
import { LayoutByRenderProps } from './components/LayoutByRenderProps';
const App: FC = () => {
return (
<div>
<LayoutByRenderProps
renderHeader={() => 'レイアウトコンポーネント by RenderProps'}
renderBody={() => 'コンテンツ'}
renderFooter={() => 'フッター'}
/>
</div>
)
}
childrenの中で識別用のコンポーネントでラップする
renderPropsのやり方はシンプルではありますが、propsに渡すためあまり直感的ではないように感じてしまいます。次はchildrenの中に書く方法を紹介します。
単純なSlotの場合
実装の大枠
childrenに書く場合はそれがヘッダー用なのか、みたいに識別できるようにする必要があります。propsのchildrenには.type
が入っていて、どのコンポーネントメソッドを使うかを知ることができます。これを使ってヘッダー用のchildren、コンテンツ用のchildren、みたいな判別をします。
const HeaderSlot = () => null;
const BodySlot = () => null;
const FooterSlot = () => null;
const Layout: FC<{ children: ReactElement[] }> = (props) => {
console.log(props.children);
const headerSlot = props.children.find((child) => child.type === HeaderSlot)?.props.children;
const bodySlot = props.children.find((child) => child.type === BodySlot)?.props.children;
const FooterSlot = props.children.find((child) => child.type === FooterSlot)?.props.children;
return (
<div>
<header>{headerSlot}</header>
<div>{bodySlot}</div>
<footer>{footerSlot}</div>
</div>
);
};
const App: FC = () => {
return (
<Layout>
<HeaderSlot>ヘッダー</HeaderSlot>
<BodySlot>コンテンツ</BodySlot>
</Layout>
)
}
上のコードを見て分かるように、HeaderSlot
やBodySlot
などはLayout
コンポーネントの方で中身を見てそれを直接呼び出しているため、HeaderSlot
自体の内容はnullを返しています。少し違和感を感じると思いますが、children
をLayout
コンポーネントのreturnに含まれていないことから、親で定義しているHeaderSlot
などがそのまま呼ばれている訳ではないことは分かると思います。
Slot周りの設定を共通化
型がちゃんと定義されていなかったり、slotの取得部分が少し冗長なのでこの辺を整理します。
// Slot.ts
import type { FC, ReactElement, ReactChild } from 'react';
type PropsChildren =
| ReactChild[]
| ReactChild
| ((...params: any[]) => ReactChild);
type SlotProps<Children extends PropsChildren = ReactChild> = {
children: Children;
};
export type SlotComponent<Children extends PropsChildren = ReactChild> = FC<
SlotProps<Children>
>;
/**
* Slot用のコンポーネントを作成する
*/
export const createSlotComponent = function <
Children extends PropsChildren = ReactChild
>(): SlotComponent<Children> {
return () => null;
};
/**
* slotコンポーネントにあるChildrenを取得する
* @param children - Reactの子要素リスト
* @param slot - 取得するslotコンポーネント
*/
export const getSlot = function <Children extends PropsChildren>(
children: ReactElement[] | ReactElement | undefined,
slot: SlotComponent<Children>
): Children | undefined {
if (Array.isArray(children)) {
const child = children.find((child) => child.type === slot);
return child ? (child.props.children as Children) : undefined;
}
if (children?.type === slot) {
return children.props.children as Children;
}
return undefined;
};
+ import { getSlot, createSlotComponent } from "./Slot";
- const HeaderSlot = () => null;
- const BodySlot = () => null;
- const FooterSlot = () => null;
+ const HeaderSlot = createSlotComponent();
+ const BodySlot = createSlotComponent();
+ const FooterSlot = createSlotComponent();
const Layout: FC<{ children: ReactElement[] }> = (props) => {
- const headerSlot = props.children.find((child) => child.type === HeaderSlot)?.props.children;
- const bodySlot = props.children.find((child) => child.type === BodySlot)?.props.children;
- const FooterSlot = props.children.find((child) => child.type === FooterSlot)?.props.children;
+ const headerSlot = getSlot(props.children, HeaderSlot);
+ const bodySlot = getSlot(props.children, BodySlot);
+ const footerSlot = getSlot(props.children, FooterSlot);
return (
<div>
<header>{headerSlot}</header>
<div>{bodySlot}</div>
<footer>{footerSlot}</div>
</div>
);
};
コンポーネントをグループ化
このままだとLayoutの配下に何を置くべきか分かりづらいので、Layout.Header
みたいにして使えるようにします。
-const Layout: FC<{ children: ReactElement[] }> = (props) => {
+const RootLayout: FC<{ children: ReactElement[] }> = (props) => {
const headerSlot = getSlot(props.children, HeaderSlot);
const bodySlot = getSlot(props.children, BodySlot);
const footerSlot = getSlot(props.children, FooterSlot);
return (
<div>
<header>{headerSlot}</header>
<div>{bodySlot}</div>
<footer>{footerSlot}</div>
</div>
);
};
+const Layout = Object.assign(RootLayout, {
+ Header: HeaderSlot,
+ Body: BlodySlot,
+ Footer: FooterSlot,
+}
const App: FC = () => {
return (
<Layout>
- <HeaderSlot>ヘッダー</HeaderSlot>
- <BodySlot>コンテンツ</BodySlot>
+ <Layout.Header>ヘッダー</Layout.Header>
+ <Layout.Body>コンテンツ</Layout.Body>
</Layout>
)
}
Slotに値を渡す場合
Vue.jsにはslot-scopeがあり、slot内で使用できる変数を渡すことができました。これをReactでも行う場合はSlotコンポーネントのchildrenに引数を受け取るようにします。
import { FC, ReactElement } from 'react';
import { getSlot, createSlotComponent } from './Slot';
type Props = {
isOpen: boolean;
onToggle: () => void;
children: ReactElement[];
};
const HeaderSlot = createSlotComponent<
(props: { isOpen: boolean; toggle: () => void }) => ReactElement
>();
const ContentSlot = createSlotComponent();
const RootAccordion: FC<Props> = (props) => {
const headerSlot = getSlot(props.children, HeaderSlot);
const contentSlot = getSlot(props.children, ContentSlot);
return (
<div>
<div>
{headerSlot &&
headerSlot({
isOpen: props.isOpen,
toggle: () => {
props.onToggle();
}
})}
</div>
<div>{props.isOpen && contentSlot}</div>
</div>
);
};
export const Accordion = Object.assign(RootAccordion, {
Header: HeaderSlot,
Content: ContentSlot
});
呼び出しは以下のようになります。
import { FC, ReactElement } from 'react';
const App: FC = () => {
const [isAccordionOpen, setIsAccordionOpen] = useState(true);
return (
<div>
<Accordion
isOpen={isAccordionOpen}
onToggle={() => {
setIsAccordionOpen(!isAccordionOpen);
}}
>
<Accordion.Header>
{({ isOpen, toggle }) => (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
アコーディオンヘッダー
<button
onClick={() => {
toggle();
}}
>
{isOpen ? '閉じる' : '開く'}
</button>
</div>
)}
</Accordion.Header>
<Accordion.Content>
<div style={{ height: '200px', backgroundColor: '#ff5' }}>
コンテンツ
</div>
</Accordion.Content>
</Accordion>
</div>
)
}
renderProps、識別用コンポーネントでラップする方法のメリットデメリット
slot的な実装で二つ紹介しましたが、それぞれメリットデメリットがあり、以下のようなことが挙げられます。
renderProps
- メリット
- 実装がシンプル
- 各項目にrequire設定ができる
- デメリット
- あまり直感的ではない
識別用コンポーネントでラップする方法
- メリット
- Vue.jsにかなり近い構成
- デメリット
- 実装が複雑
- 全てoptional扱いになる
- ただし
getSlot
メソッドで取得できなかったらthrowする仕様にすれば実行時ではあるが必須にすることはできる
- ただし
- 目的のslotをfindして探すため若干コストがかかる
- とは言えslot的な使い方をする場合は
children
にそこまで数をおかないと思うため、そこまで気にしなくて良いかも
- とは言えslot的な使い方をする場合は
終わりに
以上がReactでVue.jsのslot的なことをする方法でした。children
内で書けるようになると大分Vue.jsに近づきますが、実装が割と複雑になったなと感じました。必須かどうかの調整のしやすさも勘案してrenderPropsを使うパターンもありなのかもと個人的には感じました。
最後にサンプルコードをCodeSandboxに書きましたので、詳細を見たい方はこちらをご参照ください。
参考記事
Discussion