Featured image of post asChild Pattern

asChild Pattern

Reactでコンポーネントを使う時に、表示される要素を切り替えたい。

そんな時に asChild パターンが利用できる。

asChild パターンとは何か

asChildtrue のときにコンポーネントの子要素としてカスタムコンポーネントを渡すパターン。

  • asChild false のときデフォルトとして指定したコンポーネントをレンダリングする
  • asChild true のとき渡した子要素をレンダリングする

Radix UIでの採用が有名らしい。

asChild パターンの実装

<a> タグや <Link> タグを子要素として描画できる Buttonコンポーネントを作ってみる。

型定義

Propsの型を定義する。

1
2
3
4
5
6
7
8
9
type AsChildProps<DefaultElementProps> =
  | ({ asChild?: false } & DefaultElementProps)
  | { asChild: true; children: React.ReactNode }

type ButtonProps = AsChildProps<
  React.ButtonHTMLAttributes<HTMLButtonElement>
> & {
  style?: React.CSSProperties
}

AsChildProps 型はジェネリクスとして DefaultElementProps を受け取る。

asChildtrue であれば、 React.Node 型のプロパティ children をもち、そうでない場合は引数として渡された DefaultElementProps 型のプロパティをもつ。

ButtonProps において、ジェネリクスに React.ButtonHTMLAttributes<HTMLButtonElement> を渡しているので、 asChildfalse の場合は、HTMLのButton要素と同等のpropsが期待されるようになる。

また、交差型(Intersection Types)で optionalな style をマージしている(つまり、期待されるpropsは「HTMLのButton要素」+ style となる)

Slot

Slotコンポーネントを定義する。Slotではpropsを直接の子に対してマージする。

子は1つだけ設定できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function Slot({
  children,
  ...props
}: React.HTMLAttributes<HTMLElement> & {
  children?: React.ReactNode
}) {
  // propsを統合したReactElementを生成
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    })
  }
  // 複数のchildrenは許可しない
  if (React.Children.count(children) > 1) {
    React.Children.only(null)
  }
  return null
}

JSX

型定義とSlotを利用し、Buttonコンポーネントを記述する。

1
2
3
4
5
6
7
export function Button({ asChild, ...props }: ButtonProps) {
  const Component = asChild ? Slot : "button"

  return (
    <Component {...props} />
  )
}

asChild の値に応じて、Slotをレンダリングするか、通常のButton要素をレンダリングするか切り替える。

受け取ったpropsを全て流し込む。

呼び出し側

以下のパターンで呼び出してみた。

  • ただのButtonとして扱う
  • aタグを渡す
  • asChildでaタグを渡す
  • RemixのLinkコンポーネントを渡す
  • asChildでRemixのLinkコンポーネントを渡す
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export default function AschildSample() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8", margin: '100px' }}>
      <h1>aschild Sample</h1>

      <h2>ただのButtonとして扱う</h2>
      <Button onClick={() => {alert('Clicked!!!')}}>
        Click Me!
      </Button>

      <h2>aタグを渡す</h2>
      <Button style={{color: 'green'}}>
        <a href="https://google.com">Link to Google</a>
      </Button>

      <h2>asChildでaタグを渡す</h2>
      <Button style={{color: 'green'}} asChild>
        <a href="https://google.com">Link to Google</a>
      </Button>

      <h2>RemixのLinkコンポーネントを渡す</h2>
      <Button style={{color: 'orange'}}>
        <Link to='/sample'>Link to Sample Page</Link>
      </Button>

      <h2>asChildでRemixのLinkコンポーネントを渡す</h2>
      <Button style={{color: 'orange'}} asChild>
        <Link to='/sample'>Link to Sample Page</Link>
      </Button>

    </div>
  );
}

ただのButtonとして扱う場合、onClick でクリック時の挙動を渡すことができる。

aタグ・Linkコンポーネント共に問題なく渡すことができた。

asChild を設定していないときは、buttonタグとしてレンダリングされ、設定した時はaタグとしてレンダリングされた。

参考

Built with Hugo
Theme Stack designed by Jimmy