React実験室
答えを読む前に予想して、動かして確かめる。買い物カート・復習メモ・プロフィール検索の3つの教材で、コードの小さな違いが画面にどう現れるかを比べます。
制作・運営:React Guide 運営者
実行環境:React 19.2.5 / 教材更新:2026-09-12。入力した復習メモはこの画面のメモリ内だけで扱い、保存・送信しません。
実験1:2個セットを追加したのに、1個しか増えない?
前提はuseStateとクリックイベントです。1個300円の商品が最初に1個入っています。どちらのカートも、1回のクリックで更新を2回呼び出します。違うのは更新値の渡し方だけです。
- 「2個セットを追加」を押すと、左右の個数は何個になるか予想します。
- 1回押し、個数と合計金額を比べます。
- もう1回押し、クリックをまたいだときの違いも観察します。
値を直接渡すカート
個
合計 300 円
更新関数を渡すカート
個
合計 300 円
初期値はどちらも1個。1回押した後の個数を予想してください。
比較している処理
// 直接更新:このクリックで見えている個数を使う
setDirect(direct + 1)
setDirect(direct + 1)
// 関数型更新:キューで直前に計算された個数を受け取る
setFunctional(q => q + 1)
setFunctional(q => q + 1)予想した後で読む:結果と理由
1回目は左が2個・600円、右が3個・900円。2回目は左が3個・900円、右が5個・1,500円になります。直接更新は同じクリック内で「2個に置き換える」を2回予約します。更新関数は「直前の個数に1を足す」を順に処理します。
直接更新そのものが誤りではありません。「個数を5にする」という入力欄なら値を渡すのが自然です。前の個数に追加する操作では、更新関数にすると複数の更新を組み合わせやすくなります。ここでの比較は同じ同期イベント内の更新です。
理解を確かめる
単価を500円に変えても個数の差が同じになる理由を説明できますか。また、「セット追加」ではなく「入力した数量で上書き」の場合に、どちらの書き方を選びますか。
仕組みの参照:React公式:更新キュー。続きはuseStateの解説で学べます。
実験2:並べ替えたら、復習メモの持ち主が変わった
同じ3つの教材を左右に並べています。各行は自分のstateにメモを持つコンポーネントです。左は配列の位置、右は教材のIDをkeyに使います。
- 左右それぞれの「Props」に「型を復習」と入力します。
- 順序を反転したとき、メモがPropsと一緒に移動するか予想します。
- 「教材の順序を反転」を押して、EffectとPropsの入力欄を比べます。
key = 配列のindex
key = 教材のID
現在の順序:Props → State → Effect
比較している処理
// 位置に対応する行のstateを再利用する
lessons.map((lesson, i) =>
<LessonRow key={i} name={lesson.name} />)
// 教材のIDに対応する行のstateを再利用する
lessons.map(lesson =>
<LessonRow key={lesson.id} name={lesson.name} />)予想した後で読む:結果と理由
左のメモは先頭行に残り、表示ラベルだけがEffectになります。右ではPropsのメモが末尾に移動します。Reactは兄弟要素のkeyを使って、前後のレンダーでどの行が同じものかを識別します。
入力値がどこに保存されるかも結果に影響します。この教材では各行のローカルstateを使っています。親で教材ID別に値を管理するフォームでは同じ見え方にならないことがあります。また、並べ替え・挿入・削除がなく、行の識別を必要としない固定リストまで、indexが常に不適切というわけではありません。
理解を確かめる
先頭行を削除するTODOリストでは、indexをkeyにすると何が起きるでしょうか。毎レンダーでランダムなkeyを作った場合には、入力途中のstateが保持されるかを考えてみてください。
参照:React公式:リストのレンダー、stateの保持とリセット。関連するkey警告の解説も参照できます。
実験3:最後に検索した人と、表示される人が違う
りこの検索を始め、結果を待たずにたけるへ切り替えます。たけるの応答が先に到着し、その後でりこの応答が来たら、画面には誰が残るでしょう。
- 「りこを検索」「たけるを検索」を順に押します。
- 「たけるの応答を届ける」を先に押します。
- 「りこの応答を届ける」を押し、左右を比較します。
実通信は行いません。Promiseで応答を保留し、到着順をボタンで指定する実験です。
現在の検索対象:未選択
修正前:届いた結果を表示
修正後:現在の取得だけ表示
りこのヒント:応答の到着順と、検索した順を分けて考える
最後に届いた応答が、最後に開始した検索のものとは限りません。古いEffectの結果を画面へ反映させない条件が必要です。
修正の中心になるコード
useEffect(() => {
let active = true
getProfile(query).then(profile => {
if (active) setProfile(profile)
})
return () => { active = false }
}, [query])これは結果の反映条件を示す抜粋です。検索対象が変わると前のEffectのcleanupが実行され、その取得に対応するactiveがfalseになります。通信自体を中断するコードではありません。実際のAPIではHTTPエラー・取得中の表示・入力値の検証も扱います。
結果と理由を確認する
修正前は、後から届いたりこの結果で上書きされます。修正後は、現在の検索対象であるたけるの結果を保ちます。実験をリセットし、今度はりこの応答を先に届けると、修正後はたけるの応答が来るまで取得中のままです。
次の課題
「たけるの取得中に、りこの情報が一瞬でも表示されてよいか」を考えてください。結果を無視する処理と、前の表示を消す処理は別の役割です。
実行している教材コード
以下はこの画面で使用するコンポーネントのソースです。表示用に書き写した別コードではなく、実装ファイルから直接読み込んでいます。ReactとTypeScriptのプロジェクトに配置して利用できます。見た目の再現にはTailwind CSSが必要です。
カート・keyの実装を読む
import { useState } from 'react'
const buttonClass = 'rounded-lg border border-brand-700 px-4 py-2 text-sm font-semibold text-brand-800 hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2'
export function QuantityExperiment() {
const [direct, setDirect] = useState(1)
const [functional, setFunctional] = useState(1)
const [operations, setOperations] = useState(0)
function addBundle() {
setDirect(direct + 1)
setDirect(direct + 1)
setFunctional(q => q + 1)
setFunctional(q => q + 1)
setOperations(n => n + 1)
}
return <div className="rounded-xl border border-slate-200 bg-slate-50 p-5 my-6">
<div className="grid sm:grid-cols-2 gap-4 mb-5" aria-live="polite">
<div className="rounded-lg bg-white p-4"><p className="text-sm text-slate-600">値を直接渡すカート</p><p className="text-3xl font-bold mt-2"><output aria-label="直接更新の個数">{direct}</output> 個</p><p className="text-sm mt-2">合計 {direct * 300} 円</p></div>
<div className="rounded-lg bg-white p-4"><p className="text-sm text-slate-600">更新関数を渡すカート</p><p className="text-3xl font-bold mt-2"><output aria-label="関数型更新の個数">{functional}</output> 個</p><p className="text-sm mt-2">合計 {functional * 300} 円</p></div>
</div>
<div className="flex flex-wrap gap-3"><button className={buttonClass} onClick={addBundle}>2個セットを追加</button><button className={buttonClass} onClick={() => { setDirect(1); setFunctional(1); setOperations(0) }}>カートをリセット</button></div>
<p className="mt-4 text-sm text-slate-600" role="status">{operations === 0 ? '初期値はどちらも1個。1回押した後の個数を予想してください。' : `${operations}回操作:直接更新は${direct}個、関数型更新は${functional}個。直接更新は同じレンダーの値を2回使います。`}</p>
</div>
}
const initialLessons = [{ id: 'props', name: 'Props' }, { id: 'state', name: 'State' }, { id: 'effect', name: 'Effect' }]
function LessonRow({ name, group }: { name: string; group: string }) {
const [note, setNote] = useState('')
return <label className="block rounded-lg border border-slate-200 bg-white p-3">
<span className="block text-sm font-semibold mb-2">{name}</span>
<input aria-label={`${group} ${name}のメモ`} value={note} onChange={e => setNote(e.target.value)} placeholder="復習メモを入力" className="w-full min-w-0 border border-slate-300 rounded px-3 py-2 text-sm" />
</label>
}
export function KeyExperiment() {
const [reversed, setReversed] = useState(false)
const [revision, setRevision] = useState(0)
const lessons = reversed ? [...initialLessons].reverse() : initialLessons
return <div className="rounded-xl border border-slate-200 bg-slate-50 p-5 my-6">
<div key={revision} className="grid sm:grid-cols-2 gap-5 mb-5">
<section aria-label="indexをkeyにするリスト"><h3 className="font-bold mb-3">key = 配列のindex</h3><div className="space-y-3">{lessons.map((lesson, i) => <LessonRow key={i} name={lesson.name} group="index" />)}</div></section>
<section aria-label="IDをkeyにするリスト"><h3 className="font-bold mb-3">key = 教材のID</h3><div className="space-y-3">{lessons.map(lesson => <LessonRow key={lesson.id} name={lesson.name} group="ID" />)}</div></section>
</div>
<div className="flex flex-wrap gap-3"><button className={buttonClass} onClick={() => setReversed(v => !v)}>教材の順序を反転</button><button className={buttonClass} onClick={() => { setReversed(false); setRevision(v => v + 1) }}>メモと並び順をリセット</button></div>
<p role="status" className="mt-4 text-sm text-slate-600">現在の順序:{lessons.map(l => l.name).join(' → ')}</p>
</div>
}
通信実験の実装を読む
import { useEffect, useState } from 'react'
const button = 'rounded-lg border border-brand-700 px-4 py-2 text-sm font-semibold text-brand-800 disabled:opacity-40'
// Each request owns its delivery promise. The learner controls delivery order.
type Delivery = { query: string; resolve: (value: string) => void; response: Promise<string> }
export function RequestExperiment() {
const [current, setCurrent] = useState<Delivery | null>(null)
const query = current?.query ?? ''
const [requests, setRequests] = useState<Delivery[]>([])
const [unsafe, setUnsafe] = useState('未取得')
const [safe, setSafe] = useState('未取得')
const [events, setEvents] = useState<string[]>([])
function search(name: string) {
let resolve!: (value: string) => void
const response = new Promise<string>(deliver => { resolve = deliver })
const request = { query: name, resolve, response }
setCurrent(request)
setRequests(previous => [...previous, request])
setSafe('取得中')
setEvents(previous => [...previous, `${name}の取得を開始`])
}
useEffect(() => {
if (!current) return
let active = true
const response = current.response
response.then(value => {
setUnsafe(value)
if (active) setSafe(value)
setEvents(previous => [...previous, `${value}が到着${active ? ':両方を更新' : ':修正版は古い結果を無視'}`])
})
return () => { active = false }
}, [current])
return <div className="rounded-xl border border-slate-200 bg-slate-50 p-5 my-6">
<p className="mb-4 text-sm">実通信は行いません。Promiseで応答を保留し、到着順をボタンで指定する実験です。</p>
<div className="flex flex-wrap gap-3"><button className={button} disabled={query !== ''} onClick={() => search('りこ')}>1. りこを検索</button><button className={button} disabled={query !== 'りこ'} onClick={() => search('たける')}>2. たけるを検索</button></div>
<p className="my-4">現在の検索対象:<strong>{query || '未選択'}</strong></p>
<div className="flex flex-wrap gap-3">{requests.map((request, i) => <button key={`${request.query}-${i}`} className={button} onClick={() => { request.resolve(`${request.query}のプロフィール`); setRequests(previous => previous.filter(item => item !== request)) }}>{request.query}の応答を届ける</button>)}</div>
<div className="grid sm:grid-cols-2 gap-4 mt-5" aria-live="polite"><div className="bg-white rounded-lg p-4"><h3>修正前:届いた結果を表示</h3><output aria-label="修正前の検索結果">{unsafe}</output></div><div className="bg-white rounded-lg p-4"><h3>修正後:現在の取得だけ表示</h3><output aria-label="修正後の検索結果">{safe}</output></div></div>
<ol aria-label="通信の記録" className="text-sm mt-4 space-y-2">{events.map((event, i) => <li key={i}>{i + 1}. {event}</li>)}</ol>
</div>
}
export function ResettableRequestExperiment() {
const [revision, setRevision] = useState(0)
return <><RequestExperiment key={revision} /><button className={button} onClick={() => setRevision(value => value + 1)}>通信実験をリセット</button></>
}
自動テストでは、連続更新、並べ替え後の入力保持、通信の両方の到着順とリセットを確認しています。実際のネットワーク速度や外部APIの可用性を検証する教材ではありません。