⚛️ React Boundary
基礎 — JSX

🔧 JSXは何に変換されるのか?

JSXはHTMLのように見えるが、実はただのJavaScript関数呼び出しです。 ビルド時に何が起きているのか、旧来のトランスフォームと新しいトランスフォームの違いを深掘りします。

🤔 問題提起

Reactを使い始めたとき、こんな疑問を持ったことはないでしょうか?

  • JSXはHTMLに似ているが、なぜTypeScript(JavaScript)ファイルの中に書けるのか?
  • ブラウザはJSXを直接理解できないはずなのに、なぜ動くのか?
  • React 17以前はimport React from 'react'が必要だったのに、なぜ今は不要なのか?
  • <>...</>というFragmentの書き方は何に変換されるのか?

これらはすべて「JSXのトランスフォーム」という仕組みを理解すれば解決します。 JSXは実はビルドツール(BabelやTypeScriptのコンパイラ)によって、純粋なJavaScript関数呼び出しに変換されます。

🎯 結論から言う

JSXはビルド時に関数呼び出しに変換されます。

React 17以前は React.createElement()、React 17以降は jsx() 関数(react/jsx-runtime)に変換されます。

🗺️ 変換の全体像

JSXが最終的にDOMになるまでの流れを整理します。変換はビルド時(静的)とランタイム(動的)に分かれています。

JSXからDOMまでの変換パイプライン
① JSX構文(ソースコード)
<div className="foo">Hello</div>
↓ Babel / tsc(ビルド時)
② JavaScript関数呼び出し
jsx("div", { className: "foo" }, "Hello")
↓ Reactランタイム(実行時)
③ ReactElementオブジェクト
{ type: "div", props: { className: "foo", children: "Hello" }, ... }
↓ Reactレンダラー(reconciliation)
④ Fiberノード(内部管理)
Fiber { type, pendingProps, memoizedState, child, sibling, ... }
↓ commit phase
⑤ 実際のDOMノード
<div class="foo">Hello</div>

📜 旧トランスフォーム(React 17以前)

React 16以前のBabelおよびTypeScriptは、JSXをReact.createElement()呼び出しに変換していました。 この変換は「クラシックトランスフォーム」と呼ばれます。

旧トランスフォーム — 変換前後の比較
変換前(JSX)
// ファイルの先頭にこのimportが必須だった
import React from 'react';

function Greeting({ name }) {
  return (
    <div className="greeting">
      <h1>Hello, {name}!</h1>
      <p>Welcome to React.</p>
    </div>
  );
}
↓ Babel変換後
変換後(旧トランスフォーム)
import React from 'react';

function Greeting({ name }) {
  return React.createElement(
    "div",
    { className: "greeting" },
    React.createElement("h1", null, "Hello, ", name, "!"),
    React.createElement("p", null, "Welcome to React.")
  );
}

なぜimport Reactが必要だったのか

旧トランスフォームではJSXがReact.createElement()に変換されます。 つまり変換後のコードにReactという識別子が使われるため、 スコープにReactオブジェクトが存在しなければなりません。

仮にimport React from 'react'を忘れると、 ランタイムエラー「React is not defined」が発生します。 これはJSXをHTMLのように書いている人には非直感的で、よくある初歩的なミスでした。

React.createElementのシグネチャ
React.createElement(
  type,    // 'div' などの文字列、またはコンポーネント関数/クラス
  props,   // プロパティのオブジェクト(nullも可)
  ...children  // 子要素(可変長引数)
)

第3引数以降が子要素として展開され、propsオブジェクトの childrenプロパティにまとめられます。 子が1つの場合は直接値、複数の場合は配列になります。

⚡ 新トランスフォーム(React 17以降)

React 17でReactチームはBabelと協力して新しいJSXトランスフォームを導入しました。 これにより、JSXを含むファイルにimport React from 'react'を 書く必要がなくなりました。

新トランスフォーム — 変換前後の比較
変換前(JSX)— importが不要
// import React from 'react' が不要!
function Greeting({ name }) {
  return (
    <div className="greeting">
      <h1>Hello, {name}!</h1>
      <p>Welcome to React.</p>
    </div>
  );
}
↓ Babel変換後(新トランスフォーム)
変換後 — importが自動挿入される
// Babelが自動的にimportを挿入する
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';

function Greeting({ name }) {
  return _jsxs("div", {
    className: "greeting",
    children: [
      _jsx("h1", { children: ["Hello, ", name, "!"] }),
      _jsx("p", { children: "Welcome to React." })
    ]
  });
}

jsx() と jsxs() の違い

新トランスフォームには2つの関数があります。

  • jsx(type, props) — 子が1つまたは0個の要素
  • jsxs(type, props) — 子が複数の要素(children配列)
  • jsxDEV() — 開発モード専用(ソース位置情報を含む)

jsxsは主にパフォーマンス最適化のための分岐で、 内部的には同じReactElementオブジェクトを生成します。

新トランスフォームのメリット

  • ファイルごとのimport Reactが不要になった
  • バンドルサイズがわずかに削減(不要なReactオブジェクト参照がなくなる)
  • 将来的なJSX改善(Reactに依存しない構文拡張)が容易になる
  • 型推論・エラーメッセージが改善された(jsxDEVがより詳細な情報を持つ)

🧱 ReactElementオブジェクトの構造

React.createElement()または jsx()は、どちらも ReactElementオブジェクトを返します。 これはただのプレーンなJavaScriptオブジェクトです。

ReactElementオブジェクトの型定義
interface ReactElement {
  // 要素の種類: 'div', 'span' などのDOM文字列、
  // またはコンポーネント関数・クラス
  type: string | ComponentType;

  // 要素を識別するキー(リスト描画で使用)
  key: string | null;

  // 参照(useRef / createRef)
  ref: Ref | null;

  // コンポーネントに渡されるプロパティ
  // childrenもここに含まれる
  props: {
    children?: ReactNode;
    [key: string]: any;
  };

  // XSS対策フラグ(後述)
  $$typeof: symbol;
}
実際に出力されるオブジェクトの例
// <button onClick={handleClick} className="btn">Click me</button>
// が変換されると以下のオブジェクトになる

{
  $$typeof: Symbol(react.element),  // XSS対策
  type: "button",
  key: null,
  ref: null,
  props: {
    onClick: handleClick,  // 関数参照
    className: "btn",
    children: "Click me"
  }
}

$$typeofとXSS防止の仕組み

$$typeof: Symbol(react.element)は 一見地味ですが、XSS(クロスサイトスクリプティング)攻撃を防ぐための重要な安全機構です。

攻撃シナリオを考えます:サーバーからJSONでデータを受け取り、そのデータをそのままReactに渡す場合、 攻撃者がJSON内にReactElementのような構造({ type: 'script', props: { dangerouslySetInnerHTML: ... } })を 埋め込む可能性があります。

しかしSymbolはJSONにシリアライズできません(JSONにはSymbol型が存在しない)。 そのため、サーバーからのJSONにはこのシンボルが含まれず、Reactは「これは正規のReactElementではない」と判断して拒否します。

🧩 コンポーネント呼び出しの変換

DOM要素だけでなく、Reactコンポーネントもまったく同じ方法で変換されます。 違いはtypeが文字列ではなく関数参照になる点です。

コンポーネントのJSX変換
// JSX(変換前)
function App() {
  return (
    <UserCard
      userId={42}
      isAdmin={true}
      onLogout={() => console.log('logout')}
    />
  );
}

// 変換後(新トランスフォーム)
import { jsx as _jsx } from 'react/jsx-runtime';

function App() {
  return _jsx(UserCard, {  // ← 文字列ではなく関数参照!
    userId: 42,
    isAdmin: true,
    onLogout: () => console.log('logout')
  });
}

// 生成されるReactElement
{
  $$typeof: Symbol(react.element),
  type: UserCard,  // ← 関数自体への参照
  props: {
    userId: 42,
    isAdmin: true,
    onLogout: [Function]
  },
  key: null,
  ref: null
}

Reactはこのオブジェクトを受け取ると、typeが関数であることを確認し、 その関数をpropsを引数として呼び出します。 これがコンポーネントのレンダリングの本質です。

🔗 Fragmentの変換

<>...</>という書き方(Fragment短縮構文)も 同様に変換されます。React.Fragmentという特殊な型を使います。

Fragment変換の例
// 変換前(JSX)
function List() {
  return (
    <>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </>
  );
}

// 変換後(新トランスフォーム)
import { jsxs as _jsxs, Fragment as _Fragment } from 'react/jsx-runtime';

function List() {
  return _jsxs(_Fragment, {
    children: [
      _jsx("li", { children: "Item 1" }),
      _jsx("li", { children: "Item 2" }),
      _jsx("li", { children: "Item 3" })
    ]
  });
}

// <React.Fragment key="myKey"> という明示的な書き方の変換
// _jsxs(Fragment, { children: [...] }, "myKey")

Fragmentが存在する理由

Reactのコンポーネントは単一のルート要素を返す必要があります(JSXの制約)。 しかしDOMに不要なラッパー要素を追加したくない場合に、 Fragmentを使うことで複数の要素をグループ化できます。 Fragmentはレンダリング時に実際のDOMノードを生成しません。

🔍 具体的なコード例で確認する

より複雑なJSXの変換を確認してみましょう。条件分岐、リスト、イベントハンドラーを含む実用的な例です。

実践的なJSX変換例
変換前(JSX)
function TodoList({ todos, onDelete }) {
  if (todos.length === 0) {
    return <p className="empty">タスクはありません</p>;
  }

  return (
    <ul className="todo-list">
      {todos.map((todo) => (
        <li key={todo.id} className={todo.done ? 'done' : ''}>
          <span>{todo.title}</span>
          <button onClick={() => onDelete(todo.id)}>削除</button>
        </li>
      ))}
    </ul>
  );
}
↓ 変換後(新トランスフォーム)
変換後
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';

function TodoList({ todos, onDelete }) {
  if (todos.length === 0) {
    return _jsx("p", { className: "empty", children: "タスクはありません" });
  }

  return _jsx("ul", {
    className: "todo-list",
    children: todos.map((todo) =>
      _jsxs("li", {
        className: todo.done ? 'done' : '',
        children: [
          _jsx("span", { children: todo.title }),
          _jsx("button", {
            onClick: () => onDelete(todo.id),
            children: "削除"
          })
        ]
      }, todo.id)  // ← key は第3引数として渡される
    )
  });
}

keyの扱いに注目

keyプロパティは特別な扱いを受けます。 propsオブジェクトには含まれず、jsx/jsxs関数の第3引数として渡されます。 これはReactが内部的にkeyをpropsとは区別して管理するためです。 コンポーネント内でprops.keyにアクセスするとundefinedになります。

⚙️ 仕組みの分解:JSXは「評価される式」

JSXが関数呼び出しに変換されるということは、JSXは式(expression)として評価されます。 つまり、変数に代入したり、条件演算子の結果として使ったり、配列の要素にすることができます。

JSXが式であることを活用するパターン
// ✅ 変数への代入(JSXは関数の戻り値なので代入可能)
const element = <div>Hello</div>;

// ✅ 三項演算子(どちらもReactElementを返す)
const button = isLoading
  ? <Spinner />
  : <button>Submit</button>;

// ✅ 配列の要素(mapで生成するリスト)
const items = todos.map(t => <li key={t.id}>{t.title}</li>);

// ✅ 関数の引数(高階コンポーネントのパターン)
renderModal(<ConfirmDialog message="Are you sure?" />);

// ⚠️ JSXを返す関数は必ずReactElementを返している
function renderIcon(name) {
  switch(name) {
    case 'check': return <CheckIcon />;
    case 'x':     return <XIcon />;
    default:      return null;  // nullは有効な戻り値
  }
}

JSXとReactは独立している

JSXはReact専用の構文ではありません。BabelやTypeScriptの jsxImportSource設定を変更することで、 JSXを異なるランタイム(PreactやSolidJSなど)の関数呼び出しに変換できます。

// tsconfig.json でSolidJSのランタイムを使う例
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "solid-js/h"  // solid-jsのjsx関数を使用
  }
}

// Preactの場合
/** @jsxImportSource preact */
// このコメントで特定ファイルだけランタイムを切り替えられる

📌 まとめ

  • ✓ JSXはブラウザが理解できる構文ではなく、ビルド時にJavaScript関数呼び出しに変換される
  • ✓ 旧トランスフォーム(React 17以前)はReact.createElement()に変換——importが必須だった
  • ✓ 新トランスフォーム(React 17以降)はjsx/jsxs関数に変換——importはBabelが自動挿入
  • ✓ 変換結果はReactElementオブジェクト——typeとpropsを持つプレーンなJSオブジェクト
  • ✓ $$typeofシンボルはXSS対策の安全機構——JSON経由の偽造ReactElementを防ぐ
  • ✓ keyはpropsではなく第3引数——コンポーネント内でprops.keyにはアクセスできない
  • ✓ JSXはReact専用ではない——jsxImportSourceで別ランタイムに向けることができる

関連記事