🍣

react childrenへのprops付与

2023/12/18に公開

概要

コンポーネントを作るときにchildrenに対してprops要素をつけたい時があるかと思います。
以下のようにchildrenの先頭に例えばdate-stateのようなpropsを渡したいんよなという時です。あるいは親コンポーネントのdivをコンポーネントに付けたくないけど仕方なしに使っている場合があるかと思います。

<div data-state='open'>
{children}
</div>

最終的に作るコンポーネント

以下のようなコンポーネントを作ります。


  <Primitive.div  asChild data-state={'hoge'} className={'flex justify-center'}>
    <div className='text-red-100'>
      <div className='mr-5'>
	aaa
      </div>
      <div>
	bbb
      </div>
    </div>
  </Primitive.div>

実際のDOMは以下のようになります。Primitive.divのdivのelementが消え、そこについていたpropsが子コンポーネントのdivに付与されるようになります。

<div class="flex justify-center text-red-100" data-state="hoge">
	<div class="mr-5">aaa</div>
	<div>bbb</div>
</div>

設計

今回参考になったのは radix-uiというHeadless uiです。そのコードを必要最低限だけ切り抜いて作ってみました。登場人物は2つのコンポーネントです。

  • Primitive
    • div,a,タグのようなelementを持つコンポーネント。propsに通常のelementのpropsに加えて、asChildを持ちます。これがある場合は子コンポーネントに対してpropsを継承させます。
  • Slot
    • 親コンポーネントのpropsを子コンポーネントに渡す役目のコンポーネント

参考:https://www.radix-ui.com/

Primitive

コードは以下の通りです。雰囲気でコードを読んでもらえたらと思いますが、asChildがfalseの時はforwardrefを持っているelementができるというだけです。

import { Slot } from '@/components/Headless/Slot/Slot'

const NODES = [
  'a',
  'button',
  'div',
  'form',
  'h2',
  'h3',
  'img',
  'input',
  'label',
  'li',
  'nav',
  'ol',
  'p',
  'span',
  'svg',
  'ul',
] as const

type PrimitivePropsWithRef<E extends React.ElementType> = React.ComponentPropsWithRef<E> & {
  asChild?: boolean
}

// forwardRefをしたコンポーネントの型
type PrimitiveForwardRefComponent<E extends React.ElementType> = React.ForwardRefExoticComponent<PrimitivePropsWithRef<E>>
type Primitives = { [E in typeof NODES[number]]: PrimitiveForwardRefComponent<E> }

export const Primitive = NODES.reduce((primitive, node) => {

  const Node = React.forwardRef((props: PrimitivePropsWithRef<typeof node>, ref) => {
    const { asChild, ...primitiveProps } = props
    const Comp: any = asChild ? Slot : node

    return <Comp {...primitiveProps} ref={ref}/>
  })

  Node.displayName = `Primitive.${node}`

  return { ...primitive, [node]: Node }
}, {} as Primitives)

// 使い方
// 通常のdivエレメントと変わりがない
<Primitive.div> </Primitive.div>

// asChildがある場合は子コンポーネントの`<div></div>`に対して`data-state`が付与される
<Primitive.div asChild data-state='open'><div></div></Primitive.div>

Slot

コードは以下の通りです。

type SlotProps = React.HTMLAttributes<HTMLElement> & {
  children?: React.ReactNode
}

export const Slot = React.forwardRef<HTMLElement, SlotProps>(
  (props, forwardedRef) => {
    const { children, ...slotProps } = props
    // childrenがReactElementである場合
    if(React.isValidElement(children)) {
      return React.cloneElement<any>(children, {
        //Primitiveのpropsとchildrenのpropsをがっちゃんこします。
        ...mergeProps(slotProps, children.props),
	// Primitiveのrefとchildrenに対するrefがある場合、がっちゃんこします。
	// composeRefsはがっちゃんこさせる関数です。
        ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref,
      })
    }
    return null
  })
Slot.displayName = 'Slot'

// がっちゃんこ
const mergeProps = (slotProps: AnyProps, childProps: AnyProps) => {

  const overrideProps = { ...childProps }

  for (const propName in childProps) {
    const slotPropValue = slotProps[propName]
    const childPropValue = childProps[propName]

    //on要素についてはPrimitiveのonと子コンポーネントのonの両方を実行させる
    const isHandler = /^on[A-Z]/.test(propName)
    if(isHandler) {
      if (slotPropValue && childPropValue) {
        // slot及び子コンポーネントにhandlerがある場合は両方実行
        overrideProps[propName] = (...args: unknown[]) => {
          childPropValue(...args)
          slotPropValue(...args)
        }
      } else if (slotPropValue) {
        overrideProps[propName] = slotPropValue
      }
    }
    // デザイン系は両方を付与させる
    else if (propName === 'style') {
      overrideProps[propName] = { ...slotPropValue, ...childPropValue }
    } else if (propName === 'className') {
      overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ')
    }

  }
  return { ...slotProps, ...overrideProps }

}


type PossibleRef<T> = React.Ref<T> | undefined
const setRef = <T,>(ref: PossibleRef<T>, node: T) => {
  if (typeof ref === 'function') {
    ref(node)
  } else if (ref !== null && ref !== undefined) {

    (ref as React.MutableRefObject<T>).current = node
  }
}
// 複数のrefがある場合、そのrefに対してそのelementをいれる
export const useComposedRefs = <T,>(...refs: PossibleRef<T>[]) => {
  return React.useCallback((node: T) => refs.forEach((ref) => setRef(ref, node)), refs)
}

最終的な使い方は最初のページにある通り使うことができます。使い道はいろいろできますが、基本的にはdivのような要素を必ずしも使いたくないんだよな、というときに重宝します。

まとめ

子コンポーネントに対してpropsを付与できるものを作成しました。refのあたりは若干テクニカルですが、慣れるとこのコンポーネントは使いやすいかと思います。

Discussion