React Ariaで学ぶRender Propsパターン
Reactコンポーネントの設計パターンの1つにRender Propsパターンがあります。共通部分を子コンポーネントで横断的に使うテクニックとして紹介されていますが、あまり馴染みがありませんでした。ヘッドレスUIライブラリであるReact Ariaを触っていたところRender Propsがうまく活用されていたので、これをベースに学習した記録を記事としてまとめます。
Render Propsとは
「Render Props」で実装されたコンポーネントは、React Elementを返す関数をpropとして持ちます。
const ComponentWithRenderProp = ({
render,
}: {
render: () => React.ReactElement;
}) => {
return render();
};
// 呼び出し側
<ComponentWithRenderProp render={() => <div>Render Prop</div>} />
実態はただの関数なので、引数を渡したり、propを新しく生やすのではなく children
を使うこともできます。
export const ComponentWithRenderProp = ({
children,
}: {
children: (id: string) => React.ReactElement;
}) => {
const id = useId();
return children(id);
};
// 呼び出し側
<ComponentWithRenderProp>
{(id) => <div>{`id: ${id}`}</div>}
</ComponentWithRenderProp>
React AriaにおけるRender Props
次にボタンを例として、React Ariaのコードを見ていきましょう。
React AriaはヘッドレスUIであるため、ボタンを作るのに必要な「振る舞い」を提供してくれます。ボタンといえば、ホバーやフォーカスが当たっているときに「見た目」を変えると思いますが、Render Propsはそれらの状態にアクセスする手段として提供されており、見た目は状態を使って自由に変更できます。
React Aria Components do not include any styles by default
TypeScriptの型定義を見ると以下の様になっており、ButtonRenderProps
で定義された状態をRender propである children
の引数として受け取れることがわかります。
// ボタンのProps: `RenderProps<ButtonRenderProps>` を継承している
export interface ButtonProps extends Omit<AriaButtonProps, 'children' | 'href' | 'target' | 'rel' | 'elementType'>, HoverEvents, SlotProps, RenderProps<ButtonRenderProps> {
form?: string;
// ...
}
// Render Props: `children` の型定義を上書きして、Generic type (T) を受け取り、ReactNodeを返す関数にしている
interface RenderProps<T> extends StyleRenderProps<T> {
children?: ReactNode | ((values: T & {
defaultChildren: ReactNode | undefined;
}) => ReactNode);
}
// ボタンのRender Props: ボタンの状態が定義されている
export interface ButtonRenderProps {
isHovered: boolean;
isPressed: boolean;
// ...
}
ボタンを使ってみる
型定義から使い方が読み取れたので、ホバー時に表示テキストが切り替わるボタンをつくってみます。
import type { ComponentPropsWithRef } from "react";
import { Button } from "react-aria-components";
type Props = ComponentPropsWithRef<typeof Button>;
export const HoverButton = (props: Props) => (
<Button {...props}>
{({ isHovered }) => (isHovered ? "ホバー中 🐁" : "ホバーしてください")}
</Button>
);
汎用的なコンポーネントを提供する場合、ユースケースが無限に存在ことになります。そのため、共通部分となる振る舞いにはRender Props経由でアクセスできるのはいい設計だと思いました。
Checkbox Groupを作ってみる
最後にRender Propsを使用して、Checkbox Groupをつくってみます。
import { type ReactNode, useState } from "react";
type CheckboxGroupRenderProps = {
selectedValues: string[];
setValue: (value: string[]) => void;
};
type CheckboxGroupProps = {
children: ReactNode | ((values: CheckboxGroupRenderProps) => ReactNode);
};
export const CheckboxGroup = ({ children }: CheckboxGroupProps) => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
// `children` が Render Propであれば、状態とセッター関数を引数として渡す
if (typeof children === "function") {
return children({ selectedValues, setValue: setSelectedValues });
}
return children;
};
type CheckboxProps = CheckboxGroupRenderProps & {
label: string;
value: string;
};
export const Checkbox = ({
selectedValues,
setValue,
label,
value,
}: CheckboxProps) => {
const handleClick = () => {
const isSelected = selectedValues.includes(value);
if (isSelected) {
setValue(selectedValues.filter((v) => v !== value));
} else {
setValue([...selectedValues, value]);
}
};
return (
<label>
<span>{label}</span>
<input
type="checkbox"
checked={selectedValues.includes(value)}
onClick={handleClick}
/>
</label>
);
};
import { Checkbox, CheckboxGroup } from "@/components/checkbox-group";
export default function Home() {
return (
<CheckboxGroup>
{({ selectedValues, setValue }) => (
<>
<Checkbox
label="🍎"
value="apple"
selectedValues={selectedValues}
setValue={setValue}
/>
<Checkbox
label="🍌"
value="banana"
selectedValues={selectedValues}
setValue={setValue}
/>
<Checkbox
label="🍇"
value="grape"
selectedValues={selectedValues}
setValue={setValue}
/>
<div>
<div>Selected values:</div>
<ul>
{selectedValues.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</div>
</>
)}
</CheckboxGroup>
);
}
おわりに
今回はRender Propsパターンを学びました。React Ariaだけでなく、ライブラリのAPIとしてよく見る気がするので、仕組みを理解して使いこなせる様にしていきたいです。
Discussion