🏗

React.ComponentProps 型を積極的に使おう

2022/03/26に公開2

Atomic Design でいう Atoms に相当する、汎用コンポーネントについての小話です。次の様に Props 型定義を用意し、解説している記事をよく見かけます。<input />タグを使わずコンポーネント化している理由は style を施すためかと思いますが、このコンポーネントが受け取れる Props は限定的で、メンテナンスコストが高いためお勧めできません。

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

メンテナンスコストの高い Atoms とは?

メンテナンスコストとは、例えばonFocusを新たに付与したくなった時など、新しいユースケースに対応する修正コストを指します。Inputコンポーネントの Props にonFocusを指定できるよう、以下の様に修正を加える必要があります。

Example.tsx
<Input
  value={value}
  onChange={onChange}
  onBlur={onBlur}
  onFocus={onFocus} // <- New
/>
Input.tsx
type Props = {
  value: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>
  onBlur?: React.FocusEventHandler<HTMLInputElement>
  onFocus?: React.FocusEventHandler<HTMLInputElement> // <- New
}
export const Input = ({ value, onChange, onBlur, onFocus }: Props) => (
  <input
    value={value}
    onChange={onChange}
    onBlur={onBlur}
    onFocus={onFocus} // <- New
    className={styles.input}
   />
)

Atoms は汎用的なコンポーネント群であり、様々なユースケースに応えられる必要があります。例えばdata-testidaria属性などが将来必要になることが予想されます。このコンポーネントに期待するのは、本来の<input />タグに style を施すことだけです。

React.ComponentProps 型を使う

この様なシーンでは@types/reactComponentPropsという型定義がありますので、これを使いましょう。Generics に文字列リテラルでタグ名指定をすれば、受け取りうる全ての Props 型を拾うことができます。「本来のタグとして振る舞ってほしいコンポーネント」の場合、Props の型定義は頑張ってはいけません。

Input.tsx
type Props = React.ComponentProps<'input'> // <- here

export const Input = ({ value, onChange, onBlur, onFocus }: Props) => (
  <input
    value={value}
    onChange={onChange}
    onBlur={onBlur}
    onFocus={onFocus}
    className={styles.input}
   />
)

分割代入を使う

props をひとつひとつマッピングしていては、メンテナンスコストにさほど違いはありません。<input />タグが受け取りうる全ての props は型指定できているため、あとは分割代入 (Destructuring assignment) で全て渡してしまいます。このとき、分割代入にはclassNameも含まれますが、後続でclassName={styles.input}も指定しているため、分割代入で渡したclassNameは無効になります。

Input.tsx
type Props = React.ComponentProps<'input'>

export const Input = (props: Props) => (
  <input
    {...props} // <- props を全て渡す(onFocus や className も含まれる)
    className={styles.input} // <- こちらの指定が後勝ちになる
  />
)

props で受け取った className と、コンポーネントに施しておく className を共存させたい場合、clsxなどを利用して、スタイルを合成することができます。これで、ちょっとした style 修正の余地を残すことができます。

Input.tsx
type Props = React.ComponentProps<'input'>

export const Input = ({ className, ...props }: Props) => (
  <input
    {...props} // <- className 以外、全ての props を分割代入
    className={clsx(className, styles.input)}
  />
)

一部の Props だけ除去したい場合

逆に「className を受け取りたくない・微修正を許容したくない」という設計思想もあるかと思います。そういった設計の場合、TypeScript から標準で提供されているOmit型を使いましょう。Omit<T, 'className'>の様な指定で「T」に定義されている一部のプロパティを削除できます。これで、<Input />コンポーネントは親からclassName指定されることは無くなりました。

Input.tsx
type Props = Omit<React.ComponentProps<'input'>, 'className'>

export const Input = (props: Props) => (
  <input
    {...props} // <- className は含まれない(型エラーで事前に弾かれる)
    className={styles.input}
  />
)

「親から className は指定できるものの、適用されない」という実装を放置しておくと、後から使う人は混乱するかもしれません。受け取り拒否を明示的にするため、この様にしておく方が親切でしょう。

Ref の forwarding

この議題で厄介なものが、ref の受け渡しです。Function Component として定義したコンポーネントは、refという名称で props 渡しすることができません(別名であれば可能)。react-hook-form などが採用されている場合、ref を渡せなければ使い物になりません。これは次の通り Ref の forwarding を施しておけば OK です(Generics のHTMLInputElementは適宜書き換えてください)

Input.tsx
type Props = React.ComponentProps<'input'>

export const Input = React.forwardRef<HTMLInputElement, Props>(
  ({ className, ...props }, ref) => (
    <input {...props} ref={ref} className={styles.input} />
  )
);

https://ja.reactjs.org/docs/forwarding-refs.html

以上のテクニックで、<input />タグ本来の Props を全て受け継ぐことができ、<Input />コンポーネントに任意のスタイルを適用することができました。他にも、React.ComponentProps<typeof Foo>という指定で、Fooコンポーネントに定められた Props をそのまま拝借することもできます。便利な型ですので、コンポーネント設計の際に活用してみてください。

Discussion

nanto_vinanto_vi

こんにちは。解説記事の執筆ありがとうございます。

ComponentProps 型を使うと誤って ref が渡されたときに検知できないので、ComponentPropsWithoutRef 型を使ったほうがよいと思います。

const Button: React.FC<React.ComponentProps<'button'>> = (props) => (
  <button {...props}/>
);

const App: React.VFC = () => {
  const ref = React.useRef(null);
  // 型エラーが発生しない。
  return <Button ref={ref}/>;
};
const Button: React.FC<React.ComponentPropsWithoutRef<'button'>> = (props) => (
  <button {...props}/>
);

const App: React.VFC = () => {
  const ref = React.useRef(null);
  // Type '{ ref: MutableRefObject<null>; }' is not assignable to type '...' という型エラーが発生する。
  return <Button ref={ref}/>;
};

逆に ref が渡されてもよいときは、そのことを明示するため ComponentPropsWithRef 型を使うのがよいかと思います。

const Button: React.FC<React.ComponentPropsWithRef<'button'>> = React.forwardRef((props, ref) => (
  <button ref={ref} {...props}/>
));

const App: React.VFC = () => {
  const ref = React.useRef(null);
  // 型エラーが発生しない。
  return <Button ref={ref}/>;
};

参考:

TakepepeTakepepe

ご指摘ありがとうございます。異論なく、その方が良いと思います。