TypeScript + React 実践 #2 propsとchildrenの型付け

読了 8分

#1 始まりとセットアップで最初のコンポーネントにpropsの型を付けてみました。今回はpropsの型付けの実戦的な決定を扱います。どこまで絞るか、いつunionで分岐させ、childrenをどう受け取るかまでです。

type vs interface — どちらを使うべきか #

基礎講座#3 interfaceとtype aliasで違いを整理しましたが、Reactコンポーネントのpropsでは次の一行で答えが決まります。

コンポーネントのpropsにはtypeを使う。

理由は二つです。

  1. propsはオブジェクト形一つで終わりで、宣言マージ(declaration merging)があえて必要ありません。ライブラリの型を拡張するときに意味のある機能なので、アプリコードではほぼ使うことがありません。
  2. unionやconditionalのような高度な合成がしやすいです(すぐ後のunion propsで見ることになります)。
コンポーネントのpropsはtypeで
type ButtonProps = {
  label: string;
  onClick: () => void;
};

function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

必須propと任意prop #

任意propは?で表します。呼び出す側で省略でき、受け取る側ではstring | undefinedのように扱う必要があります。

optional prop
type AvatarProps = {
  src: string;
  alt?: string;            // 省略可能
  size?: number;           // 省略可能
};

function Avatar({ src, alt = '', size = 40 }: AvatarProps) {
  return <img src={src} alt={alt} width={size} height={size} />;
}

<Avatar src="/me.png" />                     // OK
<Avatar src="/me.png" alt="プロフィール" size={64} /> // OK

ここでよく見える二つのパターンがあります。

1) デフォルト値は分割代入の時点で alt = ''のようにデストラクチャリングの時点でデフォルト値を与えると、コンポーネント本体ではaltが常にstringに絞られます — alt | undefinedの分岐を毎回しなくて済みます。

2) optional vs null 「値が無い」を表現するとき、?(省略可能)とnull(明示的に空)は違います。propsは普通?でいきます。nullはフォーム入力値のように「値を集めたが空である」を明示するときだけ意識的に使います。

よく使うHTML属性をそのまま受け取る — ComponentProps #

ボタンや入力のようなコンポーネントは普通HTML要素を一段ラップします。このとき毎回onClickclassNamedisabledを手で再定義すると損です。ComponentPropsで丸ごと受け取って拡張できます。

既存のbutton属性をそのまま + 追加prop
import type { ComponentProps } from 'react';

type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary';
};

function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className ?? ''}`}
      {...rest}
    />
  );
}

こう書くと<Button onClick={...} disabled aria-label="...">のようなすべてのHTML属性を呼び出す側で自由に使えて、自動補完もちゃんと出ます。

参考: 昔はReact.ButtonHTMLAttributes<HTMLButtonElement>を使っていました。ComponentProps<'button'>の方が短く同じ効果なので、最近はこちらの方がよく使われます。

Union props — 「これかあれか」片方だけ許す #

本当に難しいパターンはprops同士に相互排他関係があるときです。例えばボタンが<button>としてレンダリングされるときはonClickが必要で、リンク(<a>)としてレンダリングされるときはhrefが必要、というような感じです。両方を一度に受け取らないようにすると綺麗です。

このときはdiscriminated unionが答えです。

ボタンかリンクか — どちらか一方
type ButtonAsButton = {
  as: 'button';
  onClick: () => void;
  href?: never;           // 明示的にブロック
};

type ButtonAsLink = {
  as: 'a';
  href: string;
  onClick?: never;
};

type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
  children: React.ReactNode;
};

function Button(props: ButtonProps) {
  if (props.as === 'a') {
    return <a href={props.href}>{props.children}</a>;
  }
  return <button onClick={props.onClick}>{props.children}</button>;
}

呼び出す側がどんな形かに応じて異なるpropが強制されます。

union propsの呼び出し
<Button as="button" onClick={() => alert('!')}>クリック</Button>  // OK
<Button as="a" href="/about">紹介</Button>                     // OK

<Button as="button">クリック</Button>                              // ✗ onClick欠落
<Button as="a" onClick={...}>...</Button>                       // ✗ aにはonClickなし
<Button as="button" href="/x">...</Button>                      // ✗ buttonにはhrefなし

asのように分岐基準になるフィールドをdiscriminatorと呼びます。Reactでは普通kindtypevariantのような名前を使いますが、すでにHTML属性名と被るtypeよりaskindを推奨します。

childrenの型付け — ReactNodeを基本に #

Reactコンポーネントが子エレメントを受け取るパターンは非常によくあります。childrenにはどんな型を与えるべきでしょうか?

ほとんどの場合React.ReactNodeです。文字列、数値、エレメント、配列、nullundefinedまで、Reactがレンダリングできるすべてを含みます。

最もよくあるchildrenパターン
type CardProps = {
  title: string;
  children: React.ReactNode;
};

function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h3>{title}</h3>
      <div className="card-body">{children}</div>
    </section>
  );
}

<Card title="こんにちは">
  <p>本文の段落</p>
  <button>ボタン</button>
</Card>

時々もっと狭く絞りたいときがあるのですが、関数形のchildren(render prop)特定のコンポーネントだけ受け取るchildrenです。

1) render prop childrenが関数のときは、その関数のシグネチャを書きます。

render prop
type DataLoaderProps<T> = {
  load: () => Promise<T>;
  children: (data: T) => React.ReactNode;
};

function DataLoader<T>({ load, children }: DataLoaderProps<T>) {
  // load結果をchildren(data)に渡す実装 (#3、#6で詳しく)
  return null;
}

ジェネリクスは#5 Contextとジェネリックコンポーネントで本格的に扱います。今は「childrenの型は() => ReactNodeのような関数にもなり得る」とだけ覚えておいてください。

2) 特定エレメントだけ受け取るchildren — ほぼ推奨しない<List>のchildrenは<ListItem>だけ受け取らせて」のような要求はよく聞きますが、TypeScriptのJSX型付けはそれを自然に表現するのが難しいです。普通はchildrenの代わりにデータpropで受け取ってコンポーネントが直接レンダリングする方が綺麗です。

children強制の代わりにデータで受け取る
type ListProps = {
  items: { id: string; label: string }[];
};

function List({ items }: ListProps) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.label}</li>
      ))}
    </ul>
  );
}

PropsWithChildrenヘルパー #

childrenを受け取るパターンが多いので、Reactにヘルパーがあります。

PropsWithChildren
import type { PropsWithChildren } from 'react';

type CardProps = PropsWithChildren<{
  title: string;
}>;

function Card({ title, children }: CardProps) {
  return (
    <section>
      <h3>{title}</h3>
      <div>{children}</div>
    </section>
  );
}

内部的には{ children?: React.ReactNode } & Propsとほぼ同じです。ほぼ同じというのが重要で、PropsWithChildrenはchildrenを任意にします。childrenの無い呼び出しも許したいなら、こちらの方が便利です。

明示的に書いても問題ありません — どちらのパターンもよくあるので、チームの規約に合わせてください。

Props合成 — 他のコンポーネントのpropsを再利用 #

大きなコンポーネントは普通中で小さなコンポーネントを使います。内側のコンポーネントのpropsの一部をそのまま通したいとき、そのコンポーネントのpropsの型を直接持ってくるのが良いです。

内側コンポーネントのpropsを丸ごと受け取る
import type { ComponentProps } from 'react';

function Input(props: ComponentProps<'input'>) {
  return <input {...props} />;
}

// ラベル付きのInput — 内側のInputのpropsをそのまま通す
type LabeledInputProps = ComponentProps<typeof Input> & {
  label: string;
};

function LabeledInput({ label, ...inputProps }: LabeledInputProps) {
  return (
    <label>
      <span>{label}</span>
      <Input {...inputProps} />
    </label>
  );
}

ComponentProps<typeof Input>が要点です。Inputというコンポーネントが受け取るpropsの型をそのまま引っ張ってきます。後でInputのpropsが変わればLabeledInputも自動的に追従して変わります。

このパターンはデザインシステムを作るときにほぼ毎日使うことになります。「ボタンの中にアイコンpropを追加したIconButton」のようなコンポーネントもComponentProps<typeof Button> & { icon: ... }のような形で合成します。

readonly配列/オブジェクトprops #

propsが配列やオブジェクトのとき、受け取る側で変更しないつもりならreadonlyで受け取るのが安全です。

readonly props
type TagListProps = {
  tags: readonly string[];
};

function TagList({ tags }: TagListProps) {
  // tags.push('new')  ← ✗ readonly
  return (
    <ul>{tags.map((t) => <li key={t}>{t}</li>)}</ul>
  );
}

Reactコンポーネントがpropsで受け取ったデータを直接修正するのはほぼ常にバグです。readonlyを付けておくと、そういうミスがコンパイル段階で捕まります。

まとめ #

今回は次を整理しました。

  • コンポーネントのpropsはtypeで書く
  • ?で任意prop、デストラクチャリング時点でデフォルト値
  • HTML属性をそのまま受け取るにはComponentProps<'button'>
  • 相互排他propsはdiscriminated union(as: 'button' | 'a')
  • childrenはReact.ReactNodeが基本、関数childrenはシグネチャを書く
  • PropsWithChildrenはchildrenをoptionalで追加するヘルパー
  • 他のコンポーネントのprops合成はComponentProps<typeof X>
  • 修正しない配列/オブジェクトpropsはreadonly

次の記事(#3 hooksの型付け)ではuseStateuseReduceruseRefuseCallbackuseMemoのような組み込みhookの型をどう取り、どこまで推論に任せるかを扱います。

X