🐷

React.ComponentPropsを使ったコンポーネントの Props 設計

2023/01/25に公開

はじめに

汎用的なElementレイヤーのコンポーネントを作るときの Props定義はこうした方がよいのではないか、という話です。

※ここで言うElementレイヤーとは: input, button, label など、atomic design でいう atom の部分

ComponentPropsを使おう

このように一つ一つのプロパティを定義するより

input.tsx
type Props = {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
export const Input = ({ value, onChange }: Props) => (
  <input value={value} onChange={onChange} />
);

ComponentPropsを利用して
下記のようにした方がよい、ということです。
(element が持つデフォルトの props を全て受け取れるように)

input.tsx
// input element が持つ props を全て受け取れるようになる
type Props = React.ComponentProps<'input'>;
export const Input = (props: Props) => <input {...props} />;

ComponentPropsを利用することで、React が提供している Element が持つ Props を全てうけとれるようになります。

  • ComponentProps
  • ComponentPropsWithRef:refを許容
  • ComponentPropsWithoutRef:refを許容しない

※個人的にはrefも許容したい場面が多いのでComponentPropsWithRefを利用することが多いです。

ComponentPropsを使うメリット

メリットはコンポーネントのメンテナンスコストが軽減されることにあります

ComponentPropsを使わない場合

作成当初はvalue, onChangeのみを受け取れるコンポーネントとして作成

input.tsx
type Props = {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
};
export const Input = ({ value, onChange }: Props) => (
  <input value={value} onChange={onChange} />
);

typeonBlurを利用する要件に対応させたくなった場合
propsを追加する必要があり、都度コンポーネントに手を加える必要があります。

input.tsx
type Props = {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
  type?: string; // 追加
  onBlur?: React.FocusEventHandler<HTMLInputElement>; // 追加
};
export const Input = ({ value, type, onChange, onBlur, onKeyPress }: Props) => (
  <input
    value={value}
    onChange={onChange}
    type={type} // 追加
    onBlur={onBlur} // 追加
  />
);

ComponentPropsを使うと

typeonBlurを利用する要件に対応させたくなった場合も
その都度propsを追加する必要がなくなります。

input.tsx
/**
 * React.ComponentProps<'input'>; で
 * input要素が持つPropsを全て受け取れるようになる
 *
 * value?: string;
 * type?: string;
 * onChange?: React.ChangeEventHandler<HTMLInputElement>;
 * onBlur?: React.FocusEventHandler<HTMLInputElement>;
 * onFocus?: React.FocusEventHandler<HTMLInputElement>;
 * onKeyPress?: React.KeyboardEventHandler<HTMLInputElement>;
 * etc...
 */
type InputProps = React.ComponentProps<'input'>;
export const Input = (props: InputProps) => {
  // 分割代入で全ての値を渡すことができる
  return <input {...props} />;
};

propsを全てそのまま渡すのではなく、特定の props ではコンポーネント独自の振る舞いをさせたいときは、{...props} の下にそのpropsを渡します。

ex)onChange では特定の振る舞いを強制させたいとき

input.tsx
type Props = React.ComponentProps<'input'>;
export const Input = (props: Props) => {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    // このコンポーネントで onChange 発火時に必ず実行したい振る舞いを書く
    props.onChange?.(e);
  };
  return (
    <input
      {...props} // <- onChange が含まれるものの
      onChange={handleChange} // <- こちらの handleChange が優先される
    />
  );
};

より実践的な使い方

より実践的な使い方として下記のようなインプットコンポーネントを例に考えてみます。

デフォルト

エラー

先ほどとは異なり、コンポーネント独自のPropsをもたせる必要がでてくるかと思います。

input.tsx
type InputElementProps = React.ComponentProps<'input'>;
type Props = {
  title: string; // コンポーネント独自のprops
  errorMessage?: string; // コンポーネント独自のprops
  inputElementProps?: InputElementProps; // inputElementのprops
};
export const InputComponent = ({
  title,
  errorMessage,
  inputElementProps,
}: Props) => {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    // このコンポーネントで onChange 発火時に必ず実行したい振る舞いを書く
    inputElementProps?.onChange?.(e);
  };
  return (
    <div style={rootStyle}>
      <p style={titleStyle}>{title}</p>
      <input {...inputElementProps} onChange={handleChange} />
      <p style={errorStyle}>{errorMessage}</p>
    </div>
  );
};

コンポーネントを利用するとき

index.tsx
const Index = () => {
  const [value, setValue] = useState('');
  return (
    <div>
      <InputComponent
        title="タイトル"
        errorMessage="エラーメッセージ"
        inputElementProps={{
          value,
          onChange: (e) => setValue(e.target.value),
        }}
      />
    </div>
  );
};

Element がもつ props の仕様を変更したい場合

さらに、inputvalue typepasswordemail だけに限定させたい、というユースケースがあったとします。

その場合、inputが持つデフォルトのpropsであるtypeをコンポーネントで独自に型定義して、InputElementPropstypeを除いた型にします。

input.tsx
type InputElementProps = Omit<React.ComponentProps<'input'>, 'type'>; // input element の props から type が除外される
type Props = {
  title: string;
  type: 'password' | 'email'; // password と email に限定(inputのデフォルトでは string型)
  errorMessage?: string;
  inputElementProps?: InputElementProps; // inputElementのprops
};
export const InputComponent = ({
  title,
  type,
  errorMessage,
  inputElementProps,
}: Props) => {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    // このコンポーネントで onChange 発火時に必ず実行したい振る舞いを書く
    inputElementProps?.onChange?.(e);
  };
  return (
    <div style={rootStyle}>
      <p style={titleStyle}>{title}</p>
      <input 
        {...inputElementProps}
        type={type} // ここで渡すのを忘れずに
        onChange={handleChange}
      />
      <p style={errorStyle}>{errorMessage}</p>
    </div>
  );
};

コンポーネントを利用するとき

index.tsx
const Index = () => {
  const [value, setValue] = useState('');
  return (
    <div>
      <InputComponent
        title="タイトル"
        errorMessage="エラーメッセージ"
        type='password' // password or email しか渡せない
        inputElementProps={{
          value,
          onChange: (e) => setValue(e.target.value),
          type: 'number' // error: 除外しているのでここでは渡せない
        }}
      />
    </div>
  );
};

最後に

自分自身もコンポーネント設計は、現状迷いながら実装している部分が多いです!
こうした方がよいのでは?などあれば気軽にコメントお待ちしております!

最後までお読みいただきありがとうございました!

参考資料

ありがとうございました。

Discussion