🫐

React Ariaで学ぶRender Propsパターン

2024/11/29に公開

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

https://react-spectrum.adobe.com/react-aria/styling.html

TypeScriptの型定義を見ると以下の様になっており、ButtonRenderProps で定義された状態をRender propである children の引数として受け取れることがわかります。

node_modules/react-aria-components/dist/type.d.ts
// ボタンの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;
    // ...
}

ボタンを使ってみる

型定義から使い方が読み取れたので、ホバー時に表示テキストが切り替わるボタンをつくってみます。

HoverButton.tsx
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をつくってみます。

CheckboxGroup.tsx
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>
  );
};
Home.tsx
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としてよく見る気がするので、仕組みを理解して使いこなせる様にしていきたいです。

https://github.com/yuki-yamamura/react-aria-render-props

frontend flat

Discussion