Old-School Front-End · Frameworks / 2026 ・ No.04
いにしえのフロントエンド入門 フレームワーク編
第4回 React の最初の一歩 ── ひな形、JSX、コンポーネントと props
公開
歴史の3回を終えて、いよいよ React で書きはじめます。題材はフロントエンド編の申請フォームです。
第4回は、Vite でひな形を作り、画面を部品(コンポーネント)に分け、明細を props で渡すところまで。行の追加や削除は、まだ動きません(それは第5回の「状態」の仕事です)。そのかわり、JSX の正体、よくあるつまずき、保存したら画面が変わる仕組み、開発用と本番用のちがいを測ります。
ひな形を作る
新井やっと React です! まず何を入れればいいですか?
古井Node.js だけでいい。あとはコマンド3つ。
全15回の路線図(いまここ:第4回)
React そのものは「画面の形を決める」道具で、ファイルをまとめたり開発用のサーバーを立てたりはしません。そこは Vite に任せます。第3回で Vue・Svelte・Solid をまとめたのと同じ道具です。
# npm create vite@latest keihi-react -- --template react で作られたもの(create-vite 9.3.3) .gitignore .oxlintrc.json コードの点検(oxlint)の設定 README.md index.html 入口の HTML(<div id="root"> だけ) package.json 使う部品と、dev・build のコマンド vite.config.js Vite の設定(React の部品を足すだけ) public/ そのまま配るファイル src/main.jsx 画面を #root に描きはじめる src/App.jsx いちばん外側のコンポーネント src/App.css, index.css, assets/ 見本の見た目と画像(今回は消した) # 作るのに 1.3 秒。npm install は 25 個の部品で 62 MB、17.5 秒(2回目は 4 秒)。npm run dev は約 0.3 秒で起動
入口の index.html には <div id="root"> が1つあるだけで、画面の中身はすべて src/ の JavaScript が描きます。フロントエンド編では HTML を先に書き、JavaScript があとから書きかえました。ここでは順番が逆です。
画面を部品に分ける
申請フォームを、6つの部品に分けました。部品は関数で、名前は大文字で始めます。
部品の木と、props の流れ
# src/sample.js
// 第4回:まだ「状態」を使わないので、明細は決まった見本を出す(第5回で、足したり消したりできるようにする)
import { T } from './text.js';
export const RATE = 151.23; // 1ドル何円か(第7回でサーバーから取る)
export const SAMPLE_ROWS = [
{ id: 1, date: '2026-10-14', category: 'transport', item: T.sample[0], amount: '14720', currency: 'JPY' },
{ id: 2, date: '2026-10-14', category: 'hotel', item: T.sample[1], amount: '12800', currency: 'JPY' },
{ id: 3, date: '2026-10-15', category: 'meeting', item: T.sample[2], amount: '100', currency: 'USD' },
];
# src/App.jsx(main の中)
<form>
<AboutSection />
<ItemsSection rows={SAMPLE_ROWS} rate={RATE} />
…
</form>
<ItemsSection rows={SAMPLE_ROWS} rate={RATE} /> は、HTML の属性のように見えますが、関数に引数を渡しています。この引数を props と呼びます。
# src/components/ItemsSection.jsx
import { T } from '../text.js';
import ItemTable from './ItemTable.jsx';
import TotalLine from './TotalLine.jsx';
/** 明細のカード:表と合計を並べるだけ。受け取った rows を、子にそのまま渡す */
export default function ItemsSection({ rows, rate }) {
return (
<div className="card mb-4">
<fieldset className="card-body">
<legend className="h6 section-title">{T.items} <span id="count" className="badge text-bg-secondary">{T.count(rows.length)}</span></legend>
<ItemTable rows={rows} />
<TotalLine rows={rows} rate={rate} />
</fieldset>
</div>
);
}
# src/components/ItemTable.jsx(tbody の中)
<tbody id="items">
{rows.map((row, i) => <ItemRow key={row.id} row={row} n={i + 1} />)}
</tbody>
ItemsSection は受け取った rows を、表(ItemTable)と合計(TotalLine)にそのまま渡すだけ。ItemTable は rows.map(…) で1行ずつ ItemRow を作ります。値は上から下へだけ流れ、下の部品が上の値を書きかえることはありません。第3回の「片方向」は、この形のことです。
明細の1行
import { T } from '../text.js';
/** 明細の1行。親から受け取った row(props)を、そのまま表の1行にする */
export default function ItemRow({ row, n }) {
return (
<tr>
<td><input type="date" className="form-control form-control-sm" name="date" defaultValue={row.date} aria-label={T.rowLabel(n, T.date)} /></td>
<td>
<select className="form-select form-select-sm" name="category" defaultValue={row.category} aria-label={T.rowLabel(n, T.category)}>
<option value="">{T.choose}</option>
{Object.entries(T.categories).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</td>
<td><input type="text" className="form-control form-control-sm" name="item" defaultValue={row.item} aria-label={T.rowLabel(n, T.item)} /></td>
<td><input type="text" className="form-control form-control-sm text-end" name="amount" defaultValue={row.amount} inputMode="numeric" autoComplete="off" aria-label={T.rowLabel(n, T.amount)} /></td>
<td>
<select className="form-select form-select-sm" name="currency" defaultValue={row.currency} aria-label={T.rowLabel(n, T.currency)}>
<option value="JPY">{T.jpy}</option>
<option value="USD">{T.usd}</option>
</select>
</td>
<td><button type="button" className="btn btn-outline-danger btn-sm" aria-label={T.rowRemove(n)}>{T.remove}</button></td>
</tr>
);
}
見た目は HTML ですが、ちがいがいくつかあります。class ではなく className、for ではなく htmlFor、inputmode は inputMode。{ } の中には JavaScript の式が書けて、{row.item} なら row の内容が入ります。
入力欄は defaultValue で最初の値だけ入れました。React に「この欄の値」を任せるのは第5回からで、いまは打った文字はブラウザがそのまま持っています。
JSX の正体
ブラウザは JSX を読めません。Vite がブラウザに渡す前に、ふつうの JavaScript に変えています。本番用のビルドを、縮めない設定で出してみました。
# npm run build(縮めない設定)で出てきた ItemRow。JSX は jsx()・jsxs() の呼び出しになっている
function ItemRow({ row, n }) {
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", { children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
type: "date",
className: "form-control form-control-sm",
name: "date",
defaultValue: row.date,
"aria-label": T.rowLabel(n, T.date)
}) }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", {
className: "form-select form-select-sm",
name: "category",
…
] });
}
<tr>…</tr> は jsxs("tr", { children: [...] }) という関数の呼び出しになり、属性はオブジェクトのキーになりました。ここから、JSX の決まりごとの理由が見えてきます。
- className ── オブジェクトのキーは JavaScript の名前で書く(DOM でも element.className)
- 大文字で始める ── 小文字は "tr" のような HTML の名前(文字)に、大文字は ItemRow という関数になる
- 1つにまとめて返す ── 関数は値を1つしか返せない。並べたいときは <>…</>(Fragment)で包む
合計は、計算するだけ
import { T } from '../text.js';
import { calcTotal, needsReceipt } from '../money.js';
/** 合計と、領収書のお知らせ。合計は props から毎回計算する(どこにも保存しない) */
export default function TotalLine({ rows, rate }) {
const total = calcTotal(rows, rate);
return (
<>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
<button type="button" className="btn btn-outline-primary btn-sm" id="add-row">{T.add}</button>
<p className="mb-0 fs-5">{T.total}<output id="total" className="fw-bold">{T.yen(total)}</output></p>
</div>
{needsReceipt(total) && <p className="alert alert-warning py-2 mt-3 mb-0" id="receipt-note">{T.receipt}</p>}
<p className="small text-body-secondary mt-2 mb-0" id="rate-note">{T.rate(rate)}</p>
</>
);
}
合計は rows から毎回計算して出すだけで、どこにも保存しません。フロントエンド編では、合計を画面の <output> に書きこみ、行が変わるたびに書きなおしていました(第1回で、そこがずれました)。ここでは「rows が決まれば合計も決まる」ので、ずれる場所がありません。
領収書のお知らせは {needsReceipt(total) && <p>…</p>}。条件が false なら何も出ません。hidden を付けたり外したりする代わりに、出すか出さないかを書きます。計算の関数(money.js)は、フロントエンド編の app.js に import を付けただけです。
わざとつまずく
新井HTML とほとんど同じですね。これなら間違えません。
古井じゃあ、みんながやる間違いを先に全部やっておこう。
# 開発用のサーバー(npm run dev)で、つまずきを1つずつ起こした。コンソールに出たものと、画面
class・for をそのまま
Invalid DOM property `class`. Did you mean `className`?(for も同じ)
→ 画面はそのまま出る(警告だけ)
部品名を小文字(itemRow)
<itemRow /> is using incorrect casing. … start its name with an uppercase letter.
→ <itemrow></itemrow> という知らない要素になり、行が出ない
value だけで onChange なし
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
→ 「0」を打っても 14720 のまま
map で key なし
Each child in a list should have a unique "key" prop.
→ いまは出る(第5回で、ずれる例を測る)
props を書きかえた
Cannot assign to read only property 'n' of object
→ 画面が真っ白(エラーで止まる)
内容に <img onerror=…>
(メッセージなし)
→ { } では文字のまま。dangerouslySetInnerHTML では img ができて onerror が動いた
2つの要素を並べて返した
[PARSE_ERROR] Adjacent JSX elements must be wrapped in an enclosing tag.
→ 変換で止まり、Vite のエラー画面が出る
開発用のサーバーの React は、まちがいを見つけるとコンソールに理由を書いてくれます(className の提案まで)。ただし画面は止まらないことが多く、見ていなければ気づきません。小文字の部品名は、行が1つも出ないのに、エラーではなく警告です。
XSS は、{ } で入れた文字は必ず文字のまま出ます。フロントエンド編 第8回で textContent を選んだのと同じ守りが、最初から付いています。名前のとおり危ない dangerouslySetInnerHTML を使うと、onerror が動きました。
保存すると、すぐ変わる
開発用のサーバーは、ファイルを保存すると、その部品だけを差しかえます(HMR)。件名に「大阪出張」と打ってから、いろいろなファイルを書きかえてみました。
書きかえたファイルどうなったか打った件名 components/TotalLine.jsx その部品だけ差しかえ(50 ms)残った components/AboutSection.jsx その部品だけ差しかえ(51 ms)残った sample.js App.jsx を差しかえ残った text.js ページを読みこみなおし消えた main.jsx ページを読みこみなおし消えた # 件名に「大阪出張」と打ってから、ファイルを書きかえた。時間は5回の中央値(ファイルを保存してから画面が変わるまで) # text.js は main.jsx からも読まれているので、差しかえの範囲が main.jsx まで広がり、読みこみなおしになった
部品のファイルなら 50 ms ほどで変わり、打った件名も明細の内容も残りました。ところが、画面の文字をまとめた text.js を書きかえると、ページごと読みこみなおして件名が消えました。text.js は main.jsx からも読まれていて、main.jsx は部品ではないので、差しかえられないからです。部品だけを書いたファイルにしておくと、HMR がよく効きます。
開発用と本番用
同じ画面を、npm run dev と、npm run build したものを npm run preview で配ったものとで、開いてみました。
開発用(npm run dev)本番用(npm run build) 読みこんだファイル22 4 そのうち JavaScript20 1 届いた大きさ3.8 MB 451 KB 合計が出るまで778 ms 344 ms # 開発用でいちばん大きいのは react-dom_client.js(3,132,085 B)。警告を出す仕組みと、元のソースへの対応表が入っている # 開発用では CSS も JavaScript のファイルとして届く(bootstrap.min.css をふくむ)。時間は5回の中央値
開発用と本番用
開発用は、ファイルを1つずつ配り(HMR で1つだけ差しかえるため)、六の警告を出す仕組みと、元のソースへの対応表まで入っているので 3.8 MB ありました。本番用は1つにまとめて縮めて 451 KB。速さを測るのも、人に見せるのも、本番用です。
自分のコードは何 KB か
大きさgzip React だけ(中身が <p> 1つ)218,795 B 67,378 B この回の申請フォーム229,019 B 71,093 B → 自分のコードの分10,224 B 3,715 B jQuery 版(フロントエンド編 第14回)95,275 B 35,125 B # jQuery 版は jquery・app・auth・validate・form-jq の5つ(送信や入力チェックまで動く)。この回の src は空行を除いて 228 行
中身が <p> 1つだけの React でも 219 KB あり、この回の申請フォームとの差、つまり自分で書いた分は 10 KB でした。React は「最初に払う分」が大きく、そのあとは部品を足しても少しずつしか増えません。jQuery 版は送信や入力チェックまで動いて 95 KB。いまの時点では jQuery 版のほうが軽く、できることも多いのが正直なところです。
自分の PC で
# ひな形を作って、入れて、開発用のサーバーを立てる npm create vite@latest keihi-react -- --template react cd keihi-react npm install npm run dev # http://localhost:5173/ を開く # 本番用を作って、手元で配ってみる npm run build npm run preview # http://localhost:4173/
ブラウザに React Developer Tools を入れると、開発者ツールの Components タブで、部品の木と、それぞれが受け取った props が見られます。二の図と見比べてください。なお、ひな形の main.jsx にある <StrictMode> は、開発用でだけ部品を2回動かして間違いを見つける仕組みです。第7回で、その意味を測ります。
結 — 一枚まとめと、次回
- ひな形はコマンド3つ。React は画面の形を決め、まとめるのと配るのは Vite。
- 画面は部品(関数)に分ける。値は props で上から下へだけ渡し、子は書きかえない。
- JSX は jsx() の呼び出し。className・大文字・1つにまとめる、はそこから来る。
- 合計は保存せず、毎回計算するだけ。お知らせは「出すか出さないか」を書く。
- 開発用は警告つきで 3.8 MB、本番用は 451 KB。自分のコードは 10 KB。
新井画面はできました。……「行を追加」を押しても、何も起きないんですけど。
古井押したら何が変わるのか、まだ React に教えていないからね。
古井次回は状態と再描画。useState で行を足したり消したりして、key をまちがえると入力がずれるところまで測る。
参考
- Vite — Getting Started
- React — Your First Component
- React — Writing Markup with JSX
- React — Passing Props to a Component
- React — Conditional Rendering
- React — Rendering Lists
- React — dangerouslySetInnerHTML
- Vite — Hot Module Replacement
記録は、筆者がクラウド上の Linux で、2026年11月1日に取ったものです。create-vite 9.3.3 の react のひな形(Vite 8.3.5、@vitejs/plugin-react 6.1.2、React 19.3.1、oxlint)に、Bootstrap 5.3.8 を足しました。画面は Playwright で動かした Chromium 153.0.8010.12 で開き、コンソールの内容、ファイルの数と大きさ、時間を取りました。HMR の時間はファイルを保存してから画面が変わるまで、読みこみの時間はナビゲーションの開始から合計が出るまでで、どちらも5回の中央値です。npm install の時間は通信の具合で変わります。
いにしえのフロントエンド入門 フレームワーク編 第4回 — React の最初の一歩 / 2026.11