Old-School Front-End · Frameworks / 2026 ・ No.05

いにしえのフロントエンド入門 フレームワーク編

第5回 状態と再描画 ── useState、片方向の輪、key のまちがい

公開

ブラウザの画面。React の申請フォームで、行を1つ足して4行になったところ。4行目は 2026/10/15、交通費、タクシー 新大阪→取引先、3000 円。見出しは明細 4 件、合計 45,643 円、領収書のお知らせが出ている
「行を追加」が動くようになりました。足した行に打つと、件数も合計もすぐに変わります(筆者がクラウド上の Linux で、2026年11月1日に撮影。ウインドウの上の帯は説明のために描き足したもの)

第4回で作った申請フォームは、見た目だけで「行を追加」が動きませんでした。第5回は 状態(useState)で、行の追加・削除と入力を動かします。
そのうえで、1文字打つたびに何が描きなおされているかを数え、状態のよくある3つのまちがい(配列を直接書きかえる、2回続けて変える、key を何番目かにする)を、わざと起こして測ります。

一

押しても何も起きない理由

新井「行を追加」で、配列に1行 push すればいいんですよね?

古井それで画面が変わるかどうか、六で試そう。先に、React がいつ描くかを知っておく。

全15回の路線図(いまここ:第5回)

1 なぜ FW 2 jQuery→MVC 3 React 以後 4 React の一歩 5 状態と再描画 6 フォーム 7 データ取得 8 再描画のコスト 9 状態の置き場所 10 画面遷移 11 楽観的更新 12 Server Comp. 13 キャッシュ 14 テスト 15 公開と選び方 いまここ なぜと歴史 React の基本 速さと置き場所 画面とサーバー Next.js 仕上げ

第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つだけ置きます(「状態の持ち上げ」)。

片方向の輪

状態 App の rows 部品を描く 関数をもう一度呼ぶ 画面 変わったのは2か所 人が打つ onChange が呼ばれる props で下へ 比べて書く 欄に打つ setRows(新しい配列) 値は上から下へ、「変えて」は下から上へ。状態を変える入口は setRows だけ 描きなおすのは関数の呼び出し。画面そのものが変わるのは、ちがう所だけ

値は 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;
}
端末の画面。node render5.js の結果。1文字打つと App・AboutSection・ItemsSection・ItemTable・TotalLine は開発用 2 回・本番用 1 回、ItemRow は 6 回と 3 回。行を追加では ItemRow が 8 回と 4 回。件名を打つと、どれも 0 回
部品の関数が何回呼ばれたかを数えました。開発用はちょうど2倍です(筆者がクラウド上の Linux で、2026年11月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 = row.id では ✓ はホテルに付いたまま。② key = i では ✓ が昼食に移った。③ key = i と defaultValue では、1行目の欄に消したはずの「新幹線 東京→大阪」が残り、右に出したデータは 2:ホテル 1泊、2行目の欄は「ホテル 1泊」でデータは 3:取引先との昼食、✓ は2行目
同じ操作をして、key だけを変えました。右の「2:ホテル 1泊」は、その行の本当のデータです(筆者がクラウド上の Linux で、2026年11月1日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 3行(新幹線・ホテル・昼食)で、ホテルに ✓ を付けてから、1行目の新幹線を消した
  key             内容の欄✓(要確認)欄の文字1行目に打つ
  row.id          value         ホテルに ✓正しい打てる
  i               value         昼食に ✓(ずれた)正しい打てる
  i               defaultValue  2行目に ✓(ずれた)消した「新幹線」が残った打てる(が、別の行)
  Math.random()   value         ✓ が消えた正しい1文字目で欄がなくなる
  Math.random()   defaultValue  ✓ が消えた正しい打てる(描きなおさないので)
# ✓ は行の部品が自分で持つ小さな状態(useState)。key が同じなら、React は同じ部品だと思って状態を残す

key で、どの部品がどの行につながるか

key = row.id 消す前 1行目を消したあと key=1 新幹線 key=2 ホテル ✓ key=3 昼食 key=2 ホテル ✓ key=3 昼食 key=1 の部品は消える key = i(何番目か) 消す前 1行目を消したあと key=0 新幹線 key=1 ホテル ✓ key=2 昼食 key=0 ホテル key=1 昼食 ✓ key=2 の部品は消える 部品は、消えた行といっしょに消える 部品は残り、中身だけが入れかわる React は key が同じものを「同じ部品」と見なし、部品の状態(✓)を残す 何番目か を 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行目を消すと、③の画面になります。試したら、必ず元に戻してください。

結 — 一枚まとめと、次回

  1. React が部品をもう一度描くのは、状態が変わったとき。ふつうの変数では描きなおさない。
  2. 状態は、使う部品の共通の親に1つだけ置く。値は下へ、「変えて」は上へ。
  3. 1文字で6つの部品がすべて描きなおされ(開発用は StrictMode で2倍)、画面が変わるのは2か所。
  4. 状態の配列は新しく作る。前の値から変えるなら、setRows に関数を渡す。
  5. key は行の id。何番目かにすると状態がずれ、毎回ちがう key では欄が消える。

新井行が動くようになりました! じゃあ送信ボタンを付けて……。

古井件名が空のまま送ったら? 金額に「abc」と打ったら?

古井次回はフォームと入力チェック。React 19 の Actions で送信を書き、バックエンド編 第6回のチェックとそろえる。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ