🚀

ReactでVue.jsのslot的なことをする

2022/10/30に公開

始めに

Reactは子要素にはchildrenというpropsに入るため、基本的には一つの場所にしか表示することしかできません。しかしVue.jsにはslotという機能があって、以下のようにnameを設定することでそれぞれに表示したい場所を指定することができます。

Vue.jsでslotの定義
// Layout.vue
<template>
  <div>
    <slot name="header"></slot>
    <slot name="body"></slot>
    <slot name="footer"></slot>
  </div>
</template>
Vue.jsでslotの使用
<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場所を指定することができるようになります。

renderPropsで定義
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>
  );
};
renderPropsで定義したコンポーネントの呼び出し
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、みたいな判別をします。

slotの判別イメージ
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>
  )
}

上のコードを見て分かるように、HeaderSlotBodySlotなどはLayoutコンポーネントの方で中身を見てそれを直接呼び出しているため、HeaderSlot自体の内容はnullを返しています。少し違和感を感じると思いますが、childrenLayoutコンポーネントのreturnに含まれていないことから、親で定義しているHeaderSlotなどがそのまま呼ばれている訳ではないことは分かると思います。

Slot周りの設定を共通化

型がちゃんと定義されていなかったり、slotの取得部分が少し冗長なのでこの辺を整理します。

Slotに関するtype、操作をまとめる
// 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みたいにして使えるようにします。

Layoutコンポーネントをグループ化
-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に引数を受け取るようにします。

Slotに値を渡すパターン
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
});

呼び出しは以下のようになります。

Slotに値を渡すパターン
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にそこまで数をおかないと思うため、そこまで気にしなくて良いかも

終わりに

以上がReactでVue.jsのslot的なことをする方法でした。children内で書けるようになると大分Vue.jsに近づきますが、実装が割と複雑になったなと感じました。必須かどうかの調整のしやすさも勘案してrenderPropsを使うパターンもありなのかもと個人的には感じました。
最後にサンプルコードをCodeSandboxに書きましたので、詳細を見たい方はこちらをご参照ください。

参考記事

https://medium.com/@srph/react-imitating-vue-slots-eab8393f96fd
https://zenn.dev/m10maeda/scraps/b6e0d844eb11be

Discussion