TypeScript で React を書いていると、「JSX を受け取る props にはどの型を書けばいいのか」で必ず一度は迷います。候補として出てくるのが ReactNode、ReactElement、JSX.Element の3つです。名前が似ているうえに、どれを書いても動く場面があるため、なんとなく選んでしまいがちです。この記事では @types/react の実際の定義をもとに3つの関係を整理し、children には ReactNode、戻り値の型を明示するなら ReactElement、という実務での指針を解説します。
目次
3つの型の関係を先に押さえる
結論から言うと、この3つは並列の選択肢ではありません。ReactNode が最も広く、その中に ReactElement が含まれ、JSX.Element は ReactElement のエイリアス(別名)にすぎません。つまり ReactNode ⊃ ReactElement ≒ JSX.Element という包含関係です。
| 型 | 表しているもの | 主な用途 |
|---|---|---|
ReactNode | React がレンダリングできる値すべて(文字列・数値・boolean・null・undefined・配列・要素・ポータル) | children や、JSX を受け取る props の型 |
ReactElement | createElement() が返すオブジェクト1個。type・props・key を持つ | コンポーネントの戻り値の型、要素そのものを加工する処理 |
JSX.Element | ReactElement<any, any> の別名。JSX 式を書いたときに推論される型 | 基本的に ReactElement と同じ。積極的に自分で書く必要はない |
この関係を頭に入れておくと、「広い型を要求すれば呼び出し側が自由になり、狭い型を要求すると呼び出し側が窮屈になる」という当たり前の話に落ち着きます。children のように何が渡ってくるか分からない場所で狭い型を書くのが、よくあるつまずきです。
ReactNode はレンダリングできる値すべてを含む
ReactNode は、React が画面に描画できる値をまとめたユニオン型です。@types/react の定義はおおむね次のような形になっています(実験的な内部メンバーは省略しています)。
type ReactNode =
| ReactElement
| string
| number
| bigint // React 19 向けの型定義で追加
| Iterable<ReactNode> // 配列などの反復可能なもの
| ReactPortal
| boolean
| null
| undefined;
注目したいのは、要素だけでなく文字列や数値、null、undefined、boolean、そして配列まで含まれている点です。これは JSX で実際に書ける内容とぴったり一致します。<Card>テキストだけ</Card> と書けば children は文字列になり、<Card>{items.map(...)}</Card> と書けば配列になり、<Card>{isOpen && <Body />}</Card> と書けば false が入ることもあります。ReactNode はそのすべてを受け止められます。
ちなみに boolean・null・undefined は「型として許されている」だけで、実際には何も描画されません。{isOpen && <Body />} のような条件付きレンダーが書けるのは、この仕様のおかげです。
そのため、children の型は素直に ReactNode にしておくのが正解です。
import type { ReactNode } from 'react';
type CardProps = {
title: string;
// 何が来ても受け取れるようにする
children: ReactNode;
};
export function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card__body">{children}</div>
</div>
);
}
子要素を省略できるようにしたい場合は children?: ReactNode とオプショナルにします。ただし ReactNode には undefined が含まれているので、strict 有効時に children: ReactNode と必須で書いても <Card title="A" /> のように省略した書き方はエラーになりません。子要素を必ず渡させたいなら children: ReactElement のように狭める必要がありますが、後述の理由でおすすめはしません。
ReactElement は createElement が返すオブジェクトの型
ReactElement は、createElement()(=JSX を書いたときに実行されるもの)が返す1個のオブジェクトを表す型です。DOM 要素そのものではなく、「何を描画してほしいか」を記述した設計図のようなオブジェクトです。定義は次のようになっています。
interface ReactElement<
P = any,
T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>,
> {
type: T; // 'div' などのタグ名、またはコンポーネント関数
props: P; // 渡された props
key: string | null;
}
実体を確認するとイメージしやすくなります。JSX を変数に入れて中身を覗くと、type・props・key を持つただのオブジェクトだと分かります。
const element = <a href="/about">About</a>;
console.log(element.type); // 'a'
console.log(element.props); // { href: '/about', children: 'About' }
console.log(element.key); // null
ReactElement の便利な点は、第1型引数で props の型を指定できることです。「このコンポーネントの要素だけを受け取りたい」という制約を型で表現できます。
import { Children, type ReactElement, type ReactNode } from 'react';
type TabProps = { label: string; children: ReactNode };
export function Tab({ children }: TabProps) {
return <div role="tabpanel">{children}</div>;
}
type TabListProps = {
// Tab の要素、またはその配列だけを受け取る
children: ReactElement<TabProps> | ReactElement<TabProps>[];
};
export function TabList({ children }: TabListProps) {
return (
<div>
{/* label を型安全に読み出せる */}
{Children.map(children, (child) => (
<button type="button">{child.props.label}</button>
))}
</div>
);
}
このように child.props.label を参照する必要があるときは、ReactNode ではなく ReactElement<TabProps> を要求する意味があります。逆に言えば、要素の中身に触らないなら ReactElement を要求する理由はありません。
なお、React 19 向けの型定義では第1型引数 P のデフォルトが any から unknown に変更されました。ReactElement と型引数なしで書いて element.props.foo にアクセスしていたコードは、React 19 の型に上げると unknown 由来のエラーになることがあります。その場合は ReactElement<TabProps> のように props の型を明示してください。
JSX.Element は ReactElement の別名でしかない
JSX.Element は React 固有の型ではなく、TypeScript の JSX サポートの仕組みに由来します。TypeScript は JSX 式の型を決めるとき、JSX 名前空間の Element インターフェースを参照します。React はそこに自分の型を割り当てているだけで、@types/react の定義は実質1行です。
namespace JSX {
interface Element extends React.ReactElement<any, any> {}
// IntrinsicElements(div などの組み込みタグ)などもここで定義される
}
つまり JSX.Element は ReactElement<any, any> です。props が any なので型引数で絞り込むことはできず、ReactElement より情報量が少ない型だと考えて構いません。JSX 式を書いたときにエディタのツールチップに JSX.Element と出るのは、TypeScript がこの型を割り当てているからです。
もう一つ注意点があります。@types/react 18 まではこの JSX 名前空間がグローバルに宣言されていたため、import なしで JSX.Element と書けました。React 19 向けの型定義ではグローバル宣言が廃止され、React.JSX に置かれるようになりました。React 19 の型で使うなら次のように書きます。
// React 19 の型定義では JSX 名前空間を react から取り込む
import type { JSX } from 'react';
export function Badge(): JSX.Element {
return <span className="badge">NEW</span>;
}
バージョンによって書き方が変わる型をわざわざ選ぶ理由は薄いので、戻り値の型を明示したいなら ReactElement を使うか、そもそも書かずに推論に任せるのが無難です。
children を ReactElement にすると渡せなくなるもの
「子要素はコンポーネントを1つ入れる想定だから」と children: ReactElement や children: JSX.Element と書くと、使う側で困る場面が一気に増えます。どんなコードがエラーになるのか具体的に見てみます。
import type { ReactElement } from 'react';
type PanelProps = { children: ReactElement };
export function Panel({ children }: PanelProps) {
return <section className="panel">{children}</section>;
}
// OK: 要素1個ならそのまま通る
<Panel><p>本文</p></Panel>
// エラー: 文字列は ReactElement ではない
<Panel>テキストだけ渡したい</Panel>
// エラー: 要素が2個以上だと children が配列になる
<Panel>
<h3>見出し</h3>
<p>本文</p>
</Panel>
// エラー: map の結果は ReactElement[] になる
<Panel>{items.map((item) => <Row key={item.id} {...item} />)}</Panel>
// エラー: 条件が false のとき boolean になりうる
<Panel>{isOpen && <Body />}</Panel>
// エラー: 数値も ReactElement ではない
<Panel>{count}</Panel>
いずれも Type 'string' is not assignable to type 'ReactElement' のようなメッセージになります。これらは React として何も間違っていない書き方ばかりで、型が実態より狭いことが原因です。呼び出し側は <Panel><>{...}</></Panel> のようにフラグメントで包む回避策を取ることになりますが、本来必要のない手間です。children: ReactNode にしておけば全部そのまま通ります。
要素そのものを加工したい場合に限って ReactElement を要求し、そのときも配列を許す(ReactElement | ReactElement[])か、React.Children のユーティリティで正規化するのが現実的です。
PropsWithChildren と React.FC での書き方
children: ReactNode を毎回書くのが冗長に感じるときは、React が用意している PropsWithChildren<T> が使えます。定義は次の1行です。
type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };
渡した props の型に、オプショナルな children を交差型で足すだけのユーティリティです。
import type { PropsWithChildren } from 'react';
type LayoutProps = PropsWithChildren<{ title: string }>;
// = { title: string } & { children?: ReactNode | undefined }
export function Layout({ title, children }: LayoutProps) {
return (
<main>
<h1>{title}</h1>
{children}
</main>
);
}
ここで押さえておきたいのが、@types/react 18 以降、React.FC に children が暗黙で含まれなくなったことです。React 17 までの型定義では FC(FunctionComponent)の props に children?: ReactNode が自動で足されていたため、const A: React.FC = ({ children }) => ... と書けました。この暗黙の追加は「children を受け取らないコンポーネントでも children を渡せてしまう」という問題があったため削除され、現在は自分で宣言する必要があります。
import type { FC, PropsWithChildren, ReactNode } from 'react';
// React 17 までの書き方。18 以降の型では children がエラーになる
// const NG: FC = ({ children }) => <div>{children}</div>;
// 1. props に children を明示する
const Section: FC<{ children: ReactNode }> = ({ children }) => {
return <section>{children}</section>;
};
// 2. PropsWithChildren を使う
const Article: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => {
return (
<article>
<h2>{title}</h2>
{children}
</article>
);
};
export { Section, Article };
そもそも React.FC を使わず、通常の関数宣言で props に型注釈を付ける書き方が現在の主流です。関数宣言なら戻り値は推論されますし、ジェネリックなコンポーネントも自然に書けます。
戻り値の型を書くときの選び方
コンポーネントの戻り値に型を書くかどうかは好みですが、書くなら ReactElement が扱いやすい選択です。ただし ReactElement は要素1個を表す型なので、null を返す可能性があるなら ReactElement | null と書く必要があります。
import type { ReactElement } from 'react';
type NoticeProps = { message?: string };
// 何も表示しないケースがあるので null を許す
export function Notice({ message }: NoticeProps): ReactElement | null {
if (!message) {
return null;
}
return <p role="status">{message}</p>;
}
文字列や配列をそのまま返すコンポーネントを書く場合は ReactNode を戻り値の型にします。この点は React.FC の定義もバージョンで変化しており、@types/react 18 では FunctionComponent の戻り値が ReactElement | null と定義されていたため、文字列を返す関数を FC 型の変数に代入すると型エラーになりました。React 19 向けの型定義では戻り値が ReactNode に緩和され、この制約はなくなっています。
迷うなら戻り値の型注釈を省くのが一番簡単です。JSX を返していれば TypeScript が自動的に JSX.Element(=ReactElement<any, any>)と推論してくれるので、注釈がなくても型安全性は変わりません。
ReactNode を props の一部として受け取る
ReactNode の出番は children だけではありません。「見出し部分」「アイコン」「操作ボタン」のように、複数の差し込み口を持つコンポーネントを作るときは、名前付きの props に ReactNode を使います。children は1つしか持てないため、この設計は実務でよく登場します。
import type { ReactNode } from 'react';
type DialogProps = {
// 文字列でも要素でも受け取れる
header: ReactNode;
icon?: ReactNode;
footer?: ReactNode;
children: ReactNode;
};
export function Dialog({ header, icon, footer, children }: DialogProps) {
return (
<div className="dialog" role="dialog">
<div className="dialog__header">
{icon}
{header}
</div>
<div className="dialog__body">{children}</div>
{footer && <div className="dialog__footer">{footer}</div>}
</div>
);
}
<Dialog
header="削除の確認"
icon={<WarningIcon />}
footer={
<>
<button type="button">キャンセル</button>
<button type="button">削除する</button>
</>
}
>
<p>この操作は取り消せません。</p>
</Dialog>
header に文字列も要素も渡せるのは、ReactNode が両方を含んでいるからです。もしここを ReactElement にしていたら、header="削除の確認" という一番よく使う書き方ができなくなります。
渡された値が要素かどうか判定する
ReactNode で受け取った値に対して「要素のときだけ props を加工したい」という場面では、isValidElement() を使います。これは型ガード(引数が特定の型かを型レベルで絞り込む関数)として定義されているため、if の中では ReactElement として扱えるようになります。
import { cloneElement, isValidElement, type ReactNode } from 'react';
type IconSlotProps = { icon: ReactNode };
export function IconSlot({ icon }: IconSlotProps) {
// 要素のときだけ className を追加する
if (isValidElement<{ className?: string }>(icon)) {
return cloneElement(icon, { className: 'icon icon--sm' });
}
// 文字列や数値などはそのまま出す
return <span className="icon">{icon}</span>;
}
isValidElement() は React が内部で付けているマーカーを見て判定するので、単なるオブジェクトや文字列に対しては false を返します。型引数に props の形を渡しておくと、絞り込んだ後の icon.props に型が付いて安全に扱えます。
ComponentType・ElementType は「要素」ではなく「部品」の型
混同しやすいもう一組が ComponentType と ElementType です。これらはすでに作られた要素ではなく、要素を作るための部品そのものを表します。<Icon /> が要素なら、Icon が部品です。
| 型 | 表しているもの | 渡す値の例 |
|---|---|---|
ReactElement | 描画済みの要素オブジェクト | <Icon /> |
ComponentType<P> | 関数コンポーネントまたはクラスコンポーネント | Icon |
ElementType<P> | ComponentType に加えて 'div' などの組み込みタグ名も含む | Icon、'div'、'a' |
部品を受け取る形にすると、コンポーネント側で props を決めてから描画できます。汎用ボタンのタグを切り替える as props や、アイコンだけを差し替える設計でよく使われます。
import type { ComponentType, ElementType } from 'react';
type MenuItemProps = {
label: string;
// 「アイコンの部品」を受け取り、size は呼び出し側で決めさせない
Icon: ComponentType<{ size: number }>;
// ラップするタグを差し替えられるようにする
as?: ElementType;
};
export function MenuItem({ label, Icon, as: Tag = 'li' }: MenuItemProps) {
return (
<Tag className="menu-item">
<Icon size={16} />
{label}
</Tag>
);
}
// 使う側は要素ではなく関数そのものを渡す
// <MenuItem label="設定" Icon={GearIcon} as="div" />
受け取った値をそのまま置くだけなら ReactNode、コンポーネント側で props を渡して描画したいなら ComponentType や ElementType、という切り分けで考えると迷いません。
まとめ
ReactNode・ReactElement・JSX.Element は並列の選択肢ではなく、ReactNode が最も広く、その中に ReactElement が含まれ、JSX.Element は ReactElement<any, any> の別名という関係にあります。ReactNode は文字列・数値・boolean・null・undefined・配列・要素まで含むため、children や header・icon のように JSX を差し込む props はこれを使えば呼び出し側を制限しません。ReactElement は type・props・key を持つオブジェクトの型で、child.props を読むなど要素の中身に触るときや、戻り値の型を明示するときに使います。children をうっかり ReactElement にすると、文字列・複数要素・map の結果・条件付きレンダーがすべて型エラーになるので避けてください。PropsWithChildren<T> はオプショナルな children を足すだけのユーティリティで、@types/react 18 以降は React.FC に children が暗黙で含まれないため、使う場合は自分で宣言します。値が要素かどうかは isValidElement() で判定でき、部品そのものを受け取りたいときは ComponentType や ElementType を選びます。