TypeScript + React 実践 #2 propsとchildrenの型付け
#1 始まりとセットアップで最初のコンポーネントにpropsの型を付けてみました。今回はpropsの型付けの実戦的な決定を扱います。どこまで絞るか、いつunionで分岐させ、childrenをどう受け取るかまでです。
type vs interface — どちらを使うべきか #
基礎講座#3 interfaceとtype aliasで違いを整理しましたが、Reactコンポーネントのpropsでは次の一行で答えが決まります。
コンポーネントのpropsには
typeを使う。
理由は二つです。
- propsはオブジェクト形一つで終わりで、宣言マージ(declaration merging)があえて必要ありません。ライブラリの型を拡張するときに意味のある機能なので、アプリコードではほぼ使うことがありません。
- unionやconditionalのような高度な合成がしやすいです(すぐ後のunion propsで見ることになります)。
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}必須propと任意prop #
任意propは?で表します。呼び出す側で省略でき、受け取る側ではstring | undefinedのように扱う必要があります。
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要素を一段ラップします。このとき毎回onClick、className、disabledを手で再定義すると損です。ComponentPropsで丸ごと受け取って拡張できます。
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が強制されます。
<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では普通kind、type、variantのような名前を使いますが、すでにHTML属性名と被るtypeよりasやkindを推奨します。
childrenの型付け — ReactNodeを基本に
#
Reactコンポーネントが子エレメントを受け取るパターンは非常によくあります。childrenにはどんな型を与えるべきでしょうか?
ほとんどの場合React.ReactNodeです。文字列、数値、エレメント、配列、null、undefinedまで、Reactがレンダリングできるすべてを含みます。
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が関数のときは、その関数のシグネチャを書きます。
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で受け取ってコンポーネントが直接レンダリングする方が綺麗です。
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にヘルパーがあります。
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の型を直接持ってくるのが良いです。
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で受け取るのが安全です。
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の型付け)ではuseState、useReducer、useRef、useCallback、useMemoのような組み込みhookの型をどう取り、どこまで推論に任せるかを扱います。