props と children のタイピング
props 型の定義パターン、ReactNode と ReactElement と JSX.Element の違い、PropsWithChildren を使う場面、discriminated union まで一度に扱います。
16章で最初のコンポーネントに props 型を付けてみました。本章では props タイピングの実戦上の決定 を扱います。どこまで狭めるか、いつユニオンで分岐するか、children はどう受け取るかまで。
本章のパターンは 4章(コンポーネントと props)の JavaScript 版を TypeScript の上に載せ直した結果です。さらに 24章(Server vs Client Components)で server から client コンポーネントへ props が渡るときのシリアライズ制約も、本章で揃えた型の上でもう一度押さえることになります。
type vs interface — どちらを使うべきか #
React のコンポーネント props では、次の一行で答えが決まります。
コンポーネントの props には
typeを使う。
理由は 2 つです。
- props はオブジェクトの形が一つだけで、宣言マージ(declaration merging)はあえて必要にはなりません。ライブラリの型を拡張するときくらいに意味がある機能で、アプリコードではほぼ出番がありません。
- ユニオンや 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
ここでよく見る 2 つのパターンがあります。
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 を推奨します。
このパターンは 18章(hooks のタイピング)で useReducer のアクションを捕まえる際に再び登場します。本章で基本原理を押さえておくと 18章が軽く読めます。
children のタイピング — ReactNode を基本に
#
React コンポーネントが子エレメントを受け取るパターンは非常に多いです。children にはどんな型を与えるべきでしょうか。その前に似て見える 3 つの型の違いを押さえる必要があります。
ReactNode vs ReactElement vs JSX.Element #
3 つの型はよく混同されますが、意味は明確に違います。
ReactNode— React が レンダリング可能なすべて。文字列、数値、エレメント、配列、null、undefined、boolean まで含む。もっとも広い型。ReactElement—createElement()の結果物または JSX 表現式 1 個。文字列・数値・null などは含みません。JSX.Element—ReactElementの別名に近いグローバル名前空間の型。ほとんどの場合ReactElementと同じと考えて差し支えなし。
| 型 | 含むもの | よく使う場面 |
|---|---|---|
ReactNode | レンダリング可能なすべて(文字列、数値、エレメント、配列、null、…) | children の標準、prop として受け取る子 |
ReactElement | JSX 表現式 1 個のみ | コンポーネントが返す 1 個のエレメント(通常推論に任せる) |
JSX.Element | ReactElement とほぼ同等 | 古い資料でよく見かける |
ほぼすべての children には ReactNode を使えばよいです。
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 が関数のときは、その関数のシグネチャを書きます。
type DataLoaderProps<T> = {
load: () => Promise<T>;
children: (data: T) => React.ReactNode;
};
function DataLoader<T>({ load, children }: DataLoaderProps<T>) {
// load の結果を children(data) に渡す実装(具体的な実装は 21章)
return null;
}ジェネリックは 20章(Context とジェネリックコンポーネント)で本格的に扱います。今は「children の型は (data: T) => ReactNode のような関数にもなりうる」ことだけ覚えておいてください。
特定のエレメントだけ受け取る 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 を 選択 にします。
PropsWithChildren を使う場合 vs 直接宣言する場合 #
- children が必須でない場合(ラッパーコンポーネント、オプションスロット) →
PropsWithChildrenが便利 - children が常に存在しなければならない場合(Card、Modal 本体のような構造強制) → 直接
children: React.ReactNodeを明示して必須表示 - children が関数(render prop)の場合 → 必ず直接宣言。
PropsWithChildrenはReactNodeに固定してしまう - チームの規約が決まっている場合 → そのまま従う。両パターンとも多く、大差はない
本書は意図を明確に示したいときは直接宣言、単純なラッパーは PropsWithChildren を使うように使い分けます。
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 を付けておけばその種のミスがコンパイル段階で検出されます。4章(コンポーネントと props)の「props は読み取り専用」原則が TypeScript の上でコンパイラによって強制される形です。
自分でやってみる #
Card と Button の 2 つのコンポーネントを TypeScript で書き、Button は discriminated union で button / link モードをサポートするように作ってみましょう。
src/Card.tsx:
import type { PropsWithChildren } from 'react';
type CardProps = PropsWithChildren<{
title: string;
}>;
function Card({ title, children }: CardProps) {
return (
<section style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', margin: '8px 0' }}>
<h3>{title}</h3>
<div>{children}</div>
</section>
);
}
export default Card;src/Button.tsx:
import type { ReactNode } from 'react';
type ButtonAsButton = {
as?: 'button';
onClick: () => void;
href?: never;
};
type ButtonAsLink = {
as: 'a';
href: string;
onClick?: never;
};
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
children: 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>;
}
export default Button;src/App.tsx:
import Card from './Card';
import Button from './Button';
function App() {
return (
<>
<Card title="ボタンデモ">
<Button onClick={() => alert('クリック')}>ボタンモード</Button>
<Button as="a" href="https://schoolofweb.net">リンクモード</Button>
</Card>
</>
);
}
export default App;保存すると 2 つのボタンが描画されます。今度は誤った組み合わせを試してみてください。
<Button as="a" onClick={() => {}}>X</Button> // ✗ link に onClick は不可
<Button as="a">X</Button> // ✗ href が抜けている
<Button as="button" href="/x">X</Button> // ✗ button に href は不可
3 つすべてがエディタで即座に赤線になります。
練習問題 #
Avatarコンポーネントを作り、src(必須)、alt(選択、既定値'')、size(選択、既定値 40)の 3 つの prop を受け取るように書いてみてください。<Avatar src="/x.png" />と<Avatar src="/x.png" size={64} />の 2 つの呼び出しが両方ともコンパイルを通ることを確認します。Cardのchildrenをオプションにして(PropsWithChildren)、呼び出し時に children を抜いてみてください。コンパイルを通ります。次に直接children: ReactNodeで必須宣言すると、同じ呼び出しが赤線で止まることを比較します。Buttonの discriminator をasの代わりにkindに変えてみてください。kind: 'button'/kind: 'link'でユニオンを組み直し、呼び出しコードも合わせて変えます。名前だけが変わるのではなく、discriminator が 相互排他 + ペイロード強制 の肝であることを実際に手に馴染ませてみます。
一行まとめ: コンポーネントの props は
typeで定義する。?で選択 prop、分割代入の時点で既定値。HTML 属性はComponentProps<'button'>でまるごと受け取る。相互排他の props は discriminated union(as: 'button' | 'a')。children の標準型はReactNode(もっとも広い)。PropsWithChildrenは children を optional で追加するヘルパー。他のコンポーネントの props の合成はComponentProps<typeof X>。変更しない配列・オブジェクトの props はreadonly。
次の章 #
次の 18章 hooks のタイピングでは、useState、useReducer、useRef、useCallback、useMemo のようなビルトインフックの型をどう捕まえ、どこまで推論に任せるかを扱います。特に useReducer のアクションを本章の discriminated union で捕まえ、reducer の中で自然にナローイングするパターンが肝です。