Old-School Front-End · Frameworks / 2026 ・ No.05
いにしえのフロントエンド入門 フレームワーク編
第5回 状態と再描画 ── useState、片方向の輪、key のまちがい
公開
第4回で作った申請フォームは、見た目だけで「行を追加」が動きませんでした。第5回は 状態(useState)で、行の追加・削除と入力を動かします。
そのうえで、1文字打つたびに何が描きなおされているかを数え、状態のよくある3つのまちがい(配列を直接書きかえる、2回続けて変える、key を何番目かにする)を、わざと起こして測ります。
押しても何も起きない理由
新井「行を追加」で、配列に1行 push すればいいんですよね?
古井それで画面が変わるかどうか、六で試そう。先に、React がいつ描くかを知っておく。
全15回の路線図(いまここ:第5回)
第4回の部品は、関数を1回呼んで画面の形を返しただけでした。React が部品の関数をもう一度呼ぶのは、状態が変わったとき(と、親が描きなおされたとき)だけです。ふつうの変数を書きかえても、React は気づきません。そこで「この値が変わったら描きなおして」と React に頼む入れ物、useState を使います。
状態を1か所に置く
import { useState } from 'react';
import { T } from './text.js';
import { SAMPLE_ROWS, RATE, newRow } from './sample.js';
import { countRender } from './renderCount.js';
import AboutSection from './components/AboutSection.jsx';
import ItemsSection from './components/ItemsSection.jsx';
/** 申請フォームの画面。明細の状態(rows)は、ここで1つだけ持つ */
export default function App() {
countRender('App');
const [rows, setRows] = useState(SAMPLE_ROWS);
// 状態を変える入口は、この3つだけ。どれも「新しい配列」を作って渡す
const change = (id, name, value) => setRows(rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
const add = () => setRows([...rows, newRow()]);
const remove = (id) => {
const rest = rows.filter((r) => r.id !== id);
setRows(rest.length > 0 ? rest : [newRow()]); // 最低1行は残す
};
return ( … <ItemsSection rows={rows} rate={RATE} onChange={change} onAdd={add} onRemove={remove} /> … );
}
useState(SAMPLE_ROWS) は、いまの値 rows と、値を変える関数 setRows の組を返します。状態を置いたのは、いちばん上の App です。件数(ItemsSection)も、合計(TotalLine)も、次回の送信も、同じ rows を使うので、それらの共通の親に1つだけ置きます(「状態の持ち上げ」)。
片方向の輪
値は props で上から下へ。下の部品は値を書きかえず、「変えてほしい」と onChange などの関数で上へ知らせます。状態を変える入口は change・add・remove の3つだけで、どれも新しい配列を作って setRows に渡します。
欄を状態につなぐ
import { T } from '../text.js';
import { countRender } from '../renderCount.js';
/** 明細の1行。値は row から読み、打たれたら onChange で親に知らせるだけ(自分では書きかえない) */
export default function ItemRow({ row, n, onChange, onRemove }) {
countRender('ItemRow');
const edit = (event) => onChange(row.id, event.target.name, event.target.value);
return (
<tr>
<td><input type="date" name="date" value={row.date} onChange={edit} /></td>
<td><select … name="category" value={row.category} onChange={edit}>…</select></td>
<td><input type="text" name="item" value={row.item} onChange={edit} /></td>
<td><input type="text" name="amount" value={row.amount} onChange={edit} /></td>
<td><select … name="currency" value={row.currency} onChange={edit}>…</select></td>
<td><button type="button" data-action="remove" onClick={() => onRemove(row.id)}>{T.remove}</button></td>
</tr>
);
}
第4回の defaultValue を value={row.amount} に変え、onChange を付けました。第4回の六で「value だけで onChange なし」は読み取り専用になりましたが、これで欄の値は React の状態と同じものになります。このつなぎ方を制御されたコンポーネントと呼びます。1文字打つと、onChange → setRows → 描きなおし → 欄に新しい値、と一周して戻ってきます。
同じ操作で確かめる
# 状態で動くようにした申請フォームで、操作ごとに画面を見た(ja のページ) 操作行件数の表示合計の表示 開いた3 明細 3 件42,643 円 1行目の金額に 03 明細 3 件175,123 円 Backspace で戻す3 明細 3 件42,643 円 行を追加4 明細 4 件42,643 円 4行目に 30004 明細 4 件45,643 円 3行目をドル→円4 明細 4 件30,620 円 2行目を削除3 明細 3 件17,820 円 件名に「大阪」3 明細 3 件17,820 円 # 合計はどれも手で計算した値と同じ(ドルは 151.23 円で換算)。開発用でも本番用でも同じ
件数も合計も、毎回正しく変わりました。合計は第4回と同じく rows から計算するだけなので、行を足しても消しても、ずれる場所がありません。第1回の jQuery 版で3回ずれた件数も、ここでは何もしなくても正しいままです。
何回描きなおしているか
各部品の先頭に、呼ばれた回数を数える1行を足しました(測るためだけのものです)。
// 測るためだけの1行:部品の関数が何回呼ばれたか(描かれたか)を数える。ふだんのアプリには要らない
export function countRender(name) {
const c = (globalThis.__renders ??= {});
c[name] = (c[name] ?? 0) + 1;
}
# 各部品の関数が呼ばれた回数(countRender で数えた) 部品1文字打つ行を追加件名を打つ 開発用 / 本番用開発用 / 本番用開発用 / 本番用 App 2 / 1 2 / 1 0 / 0 AboutSection 2 / 1 2 / 1 0 / 0 ItemsSection 2 / 1 2 / 1 0 / 0 ItemTable 2 / 1 2 / 1 0 / 0 ItemRow 6 / 3 8 / 4 0 / 0 TotalLine 2 / 1 2 / 1 0 / 0 # 開発用は StrictMode が部品の関数を2回呼ぶので、ちょうど2倍。件名の欄は状態につないでいないので、何も描きなおさない
1文字打つと、6つの部品がすべて描きなおされました。金額と関係のない AboutSection(件名と支払い方法)も、打っていない2行目・3行目の ItemRow もです。状態を持つ App が描きなおされると、その下はすべて呼ばれる、というのが React の決まりです。開発用でちょうど2倍なのは、<StrictMode> がわざと2回呼んで、関数が毎回同じ結果を返すかを確かめているからです。
# 同じ1文字で、画面(DOM)が実際にどれだけ変わったか(MutationObserver、開発用も本番用も同じ) 記録 42 件。そのうち中身が変わったのは 2 つ(打った欄の value と、合計の文字) 残りの 40 は、React が入力欄の name・type などを同じ値で付けなおす内部の手順(第1回の 21 件と同じ)
ただし、関数を呼ぶことと画面を書きかえることは別です。React は呼んだ結果を前回と比べ、本当に書きかえたのは打った欄と合計の2か所でした。この表の大きさなら、関数を何回呼んでも人にはわかりません。行が増えたときにどうするかは、第8回で測ります。
状態は「次の描画」の値
一で新井くんが言ったとおりに書くと、どうなるでしょう。
const addWrong = () => {
rows.push({ id: rows.length + 1, item: '(新しい行)' }); // 中身を書きかえた
setRows(rows); // 同じ配列を渡した
};
const addRight = () => setRows([...rows, { id: rows.length + 1, item: '(新しい行)' }]); // 新しい配列を渡す
const addTwoWrong = () => {
setRows([...rows, newRow()]);
setRows([...rows, newRow()]); // この rows は、まだ1行のまま
window.__seen = rows.length; // setRows のすぐあとに読んだ rows の長さ
};
const addTwoRight = () => {
setRows((prev) => [...prev, newRow()]); // 「いまの値」を受け取って、次の値を返す
setRows((prev) => [...prev, newRow()]);
};
# lab.html?lab=push と ?lab=twice 操作件数 開いた1 ① push してから同じ配列を setRows1(画面が変わらない) ② 続けて、新しい配列で1行追加3(①の行が急に出てきた) 開いた1 ③ setRows([...rows, 新しい行]) を2回2(1行しか増えない) そのすぐあとに読んだ rows.length1(まだ古い) ④ setRows((prev) => [...prev, 新しい行]) を2回4(2行増えた)
① push して同じ配列を渡すと、画面は変わりませんでした。React は「前と同じ配列か」で変わったかどうかを見るので、中身を書きかえた同じ配列は「変わっていない」と判断します。困るのは②で、次に別の方法で1行足すと、①で黙って入っていた行がいっしょに出てきました。状態の配列は書きかえず、新しく作るのが決まりです。
③は「2行まとめて追加」のつもりで setRows を2回呼んだもの。1行しか増えませんでした。rows はこの描画のときの値(スナップショット)で、setRows を呼んでもすぐには変わりません(すぐあとに読んでも 1 のまま)。2回とも「1行の配列 + 1行」を渡したので、結果は2行です。前の値をもとに変えるときは、④のように関数を渡します。React が「いまの値」を順に渡してくれます。
key で行を見分ける
第4回で「key がない」と警告が出ても、画面は出ていました。key がないと本当に困るのは、行を消したり並べかえたりしたときです。行の部品に、自分だけが持つ小さな状態(「要確認」の ✓)を足して試しました。
# 3行(新幹線・ホテル・昼食)で、ホテルに ✓ を付けてから、1行目の新幹線を消した key 内容の欄✓(要確認)欄の文字1行目に打つ row.id value ホテルに ✓正しい打てる i value 昼食に ✓(ずれた)正しい打てる i defaultValue 2行目に ✓(ずれた)消した「新幹線」が残った打てる(が、別の行) Math.random() value ✓ が消えた正しい1文字目で欄がなくなる Math.random() defaultValue ✓ が消えた正しい打てる(描きなおさないので) # ✓ は行の部品が自分で持つ小さな状態(useState)。key が同じなら、React は同じ部品だと思って状態を残す
key で、どの部品がどの行につながるか
React は描きなおすとき、key が同じものを「同じ部品」と見なして、その部品の状態や画面の要素を使い回します。key を何番目か(i)にすると、1行目を消したとたん、0番の部品(もとは新幹線)がホテルを、1番の部品(もとは ✓ のホテル)が昼食を受け持ちます。✓ は部品についているので、昼食に移りました。
③はもっと悪く、内容の欄をブラウザに任せる defaultValue にすると、欄の文字も部品といっしょに残り、消したはずの「新幹線」が画面に残りました。データはホテルなのに、です。key は「何番目か」ではなく、行そのものの id にします。
1文字で欄が消える key
表の4行目と5行目は、key を Math.random() にしたものです。描くたびに key がちがうので、React は毎回「前の部品は全部なくなり、新しい部品が来た」と見なします。
状態につないだ欄(value)では、1文字目を打った瞬間に描きなおしが起き、欄そのものが作りなおされて、カーソルが消えました。第1回の書き方 B(表を innerHTML で全部作りなおす)とまったく同じことが、React の中で起きたわけです。React の比べる仕組みは、key が正しくないと働きません。
状態にしないもの
五の表の右の列、件名を打ったときは、どの部品も描きなおされませんでした。件名の欄は状態につないでいないからです。今回、状態にしたのは明細の rows だけです。
- 合計・件数・領収書のお知らせ ── rows から計算できるので、状態にしない(二重に持つと、第1回のようにずれる)
- 件名・備考 ── 送るときに読めばよいので、いまはブラウザに任せる(第6回で、送信といっしょに考える)
- 為替レート ── 決まった値。サーバーから取るようになったら状態にする(第7回)
状態が増えるほど、描きなおしのきっかけも、ずれる場所も増えます。「ほかから計算できるなら、状態にしない」が第一の決まりです。
自分の PC で
React Developer Tools の設定(Components タブの歯車)で「Highlight updates when components render.」を有効にすると、描きなおされた部品が枠で光ります。金額を1文字打って、件名の欄まで光るのを確かめてください。件名を打つと、どこも光りません。
key の実験は、ItemTable.jsx の key={row.id} を key={i} に変えるだけでできます。2行目の内容の欄を defaultValue に戻して1行目を消すと、③の画面になります。試したら、必ず元に戻してください。
結 — 一枚まとめと、次回
- React が部品をもう一度描くのは、状態が変わったとき。ふつうの変数では描きなおさない。
- 状態は、使う部品の共通の親に1つだけ置く。値は下へ、「変えて」は上へ。
- 1文字で6つの部品がすべて描きなおされ(開発用は StrictMode で2倍)、画面が変わるのは2か所。
- 状態の配列は新しく作る。前の値から変えるなら、setRows に関数を渡す。
- key は行の id。何番目かにすると状態がずれ、毎回ちがう key では欄が消える。
新井行が動くようになりました! じゃあ送信ボタンを付けて……。
古井件名が空のまま送ったら? 金額に「abc」と打ったら?
古井次回はフォームと入力チェック。React 19 の Actions で送信を書き、バックエンド編 第6回のチェックとそろえる。
参考
- React — State: A Component's Memory
- React — Render and Commit
- React — State as a Snapshot
- React — Queueing a Series of State Updates
- React — Updating Arrays in State
- React — Sharing State Between Components
- React — Preserving and Resetting State
- React — StrictMode
記録は、筆者がクラウド上の Linux で、2026年11月1日に取ったものです。第4回のプロジェクト(Vite 8.3.5、React 19.3.1)に状態を足し、開発用(npm run dev、StrictMode あり)と本番用(npm run build を npm run preview で配ったもの)を、Playwright で動かした Chromium 153.0.8010.12 で開きました。描きなおしの回数は各部品の先頭に足した countRender で、画面の変化は MutationObserver で数えました。key などの実験は lab.html の別の画面で行っています。
いにしえのフロントエンド入門 フレームワーク編 第5回 — 状態と再描画 / 2026.11