Reactコンポーネントでchildrenに対してpropsを渡す
任意のコンポーネント(ReactNode)を子として持つコンポーネントを開発するとき、子コンポーネントのpropsを指定したいことがあります。
import { FC, ReactNode } from 'react';
type Props = {
children: ReactNode;
};
const ParentComponent: FC<Props> = ({
children,
}) => (
<div>
{/* childrenにonClick等のpropsを親から付与したい */}
{children}
</div>
);
この記事ではこのようなパターンを実装する2つの方法と、それを活用したツールチップの実装例を紹介します。
cloneElement
最初に紹介する方法はcloneElementが対象のReactElementを複製する時に、第2引数に渡したオブジェクトでpropsを上書きする機能を利用した実装です。
子コンポーネントのpropsにonClickを付与したものを子要素としてレンダリングするコンポーネントは以下のように書けます。
import { cloneElement, FC, isValidElement, ReactNode } from 'react';
const ParentComponent: FC<Props> = ({ children }) => {
const cloneChildren = cloneElement(
isValidElement(children) ? children : <button>{children}</button>,
{
onClick: () => alert('親から付与されたclickイベントです。'),
}
);
return <div>{cloneChildren}</div>;
};
cloneElementの第1引数はReactElementを渡したいので、isValidElementを用いてReactElementを抽出しました。childrenがReactElementではなかった場合はbutton要素で囲んだものを渡すようにしています。
そして、第2引数はpropsにonClickを付与するようなオブジェクトを渡しました。第2引数にオブジェクトを渡すとchildren自身が持っていたpropsが全て置き換えられることに注意して下さい。
元のpropsを保持させたい場合は以下のようにします。
const cloneChildren = isValidElement(children)
? cloneElement(children, {
// 子要素のpropsを優先したいときは順番を入れ替える
...children.props,
onClick: () => alert('親から付与されたclickイベントです。'),
})
: cloneElement(<button>{children}</button>, {
// ReactElementじゃないときは`button`に対して付与されるので考慮不要
onClick: () => alert('親から付与されたclickイベントです。'),
});
例
例として素のbutton要素を宣言したものと、ParentComponentによって素のbutton要素にonClickを付与したもの、ParentComponentによってonClickを持つbutton要素にonClickを付与した3つを準備しました。
render propパターン
続いてはrender propパターン呼ばれるpropsを利用した実装です。
コンポーネントから子コンポーネントを組み上げるrenderItemというツールキットを提供して、付与したいpropsを用いた子コンポーネントを宣言させます。
import { FC, ReactElement } from 'react';
type Props = {
renderItem: (props: { onClick: () => void }) => ReactElement;
};
const ParentComponent: FC<Props> = ({ renderItem }) => {
return (
<div>
{renderItem({
onClick: () => alert('親から付与されたclickイベントです。'),
})}
</div>
);
};
renderItemは付与させたいpropsを引数として持ち、ReactElementを返すような関数です。利用する側ではpropsを受け取って、それを付与したコンポーネントを返すようにします。
<ParentComponent
renderItem={(props) => (
<button {...props}>
親コンポーネントから渡ってきたonClickを付与したbutton要素
</button>
)}
/>
この方法では指定したいpropsを子コンポーネント側に明示的に渡せることや子コンポーネントの実装を自身でハンドリングできるところに利点があります。
例
cloneElementで紹介したものと同じような例です。
ツールチップを実装する
render propを有効活用できる例としてツールチップを実装します。
この記事ではツールチップを開閉等の状態をもつRootコンポーネントと、ツールチップの開閉のトリガーとなるTriggerコンポーネント、表示されるツールチップ自体であるContentコンポーネントから構築します。
状態
まずはツールチップの開閉等の情報を司る状態を定義します。
必要な状態は開閉についての情報であるisOpenと開閉を切り替えるonOpen・onClose、そして各コンポーネントを連携するとき活用するcontextIdの4つです。
type Context = {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
contextId: string;
};
これらの情報を各コンポーネントで扱えるようにcreateContextでコンテキストを定義します。
const TooltipContext = createContext<Context | null>(null);
const useTooltipContext = (): Context => {
const context = useContext(TooltipContext);
if (context === null) {
throw new Error('TooltipContextのProviderを定義して下さい。');
}
return context;
};
コンテキストを初期値のnullのままで活用されないようにuseTooltipContextとしてcustom hooksに切り出しました。
Tooltip.Root
次に、他のコンポーネントの親となるRootコンポーネントを実装します。
const Root: FC<{
children: ReactNode;
}> = ({ children }) => {
const contextId = useId();
const [isOpen, setIsOpen] = useState(false);
const onOpen = useCallback(() => {
setIsOpen(true);
}, []);
const onClose = useCallback(() => {
setIsOpen(false);
}, []);
// escキーを押したらツールチップを閉じさせる
useEffect(() => {
const abortController = new AbortController();
document.addEventListener(
'keydown',
(e) => {
if (e.key === 'Escape') {
onClose();
}
},
{
signal: abortController.signal,
}
);
return () => {
abortController.abort();
};
}, [onClose]);
return (
<TooltipContext.Provider
value={{
isOpen,
onOpen,
onClose,
contextId,
}}
>
<div style={{ position: 'relative' }}>{children}</div>
</TooltipContext.Provider>
);
};
先ほど定義したTooltipContextに必要な情報を定義して、TooltipContext.Providerで情報を流し込んでいます。
今回はContentコンポーネントをabsolute要素で実装しようと考えているため、position: relativeなdivも配置しています。
Tooltip.Content
続いて、Tooltipの本体であるContentコンポーネントを実装します。
const Content: FC<{ children: ReactNode }> = ({ children }) => {
const { isOpen, contextId } = useTooltipContext();
return (
<div
id={contextId}
role="tooltip"
style={{
top: '50%',
color: 'white',
backgroundColor: '#333',
borderRadius: '10px',
left: 0,
opacity: isOpen ? 1 : 0,
padding: '8px',
pointerEvents: 'none',
position: 'absolute',
transform: 'translate(-25%, -150%)',
width: 'max-content',
zIndex: 1,
}}
>
{children}
</div>
);
};
Triggerコンポーネントから参照させるためにTooltipContextのcontextIdをidとして付与しました。他はroleやstyleの付与を行なっています。
styleは汎用的なものではないので、参考にしないでください。floating-uiを用いて表示位置を求めるのがおすすめです。
Tooltip.Trigger
最後にTriggerの実装です。ここでrender propパターンを用います。
const Trigger: FC<{
renderItem: (props: {
'aria-describedby'?: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
onFocus: () => void;
onBlur: () => void;
}) => ReactElement;
}> = ({ renderItem }) => {
const { isOpen, onOpen, onClose, contextId } = useTooltipContext();
return renderItem({
['aria-describedby']: isOpen ? contextId : undefined,
onMouseEnter: onOpen,
onMouseLeave: onClose,
onFocus: onOpen,
onBlur: onClose,
});
};
TooltipContextから受け取った開閉を切り替える関数をもとにonMouseEnter、onMouseLeave、onFocus、onBlurを子コンポーネントに付与するように実装しています。onMouseEnter、onMouseLeaveでマウスカーソルを合わせることによる開閉、onFocus、onBlurでフォーカス時の開閉を実装しています。
Triggerコンポーネントを説明する内容がContentコンポーネントとして表示されるはずなので、Contentのidとして渡したcontextIdをaria-describedbyに付与しました。
ツールチップを利用する
作成したツールチップは以下のように使います。
<Tooltip.Root>
<Tooltip.Content>tooltip content</Tooltip.Content>
<Tooltip.Trigger
renderItem={(props) => <button {...props}>tooltip</button>}
/>
</Tooltip.Root>
renderItemでトリガーするコンポーネントの宣言と、そこで必要なpropsの付与を行なっています。
これによって、特定の見た目から発火するツールチップではなく利用する側でコンポーネントの見た目や振る舞いを選択できるツールチップができました。
実際に動いている様子は下から確認できます。
Discussion