React実験室

答えを読む前に予想して、動かして確かめる。買い物カート・復習メモ・プロフィール検索の3つの教材で、コードの小さな違いが画面にどう現れるかを比べます。

制作・運営:React Guide 運営者

実行環境:React 19.2.5 / 教材更新:2026-09-12。入力した復習メモはこの画面のメモリ内だけで扱い、保存・送信しません。

実験1:2個セットを追加したのに、1個しか増えない?

前提はuseStateとクリックイベントです。1個300円の商品が最初に1個入っています。どちらのカートも、1回のクリックで更新を2回呼び出します。違うのは更新値の渡し方だけです。

  1. 「2個セットを追加」を押すと、左右の個数は何個になるか予想します。
  2. 1回押し、個数と合計金額を比べます。
  3. もう1回押し、クリックをまたいだときの違いも観察します。

値を直接渡すカート

1 個

合計 300 円

更新関数を渡すカート

1 個

合計 300 円

初期値はどちらも1個。1回押した後の個数を予想してください。

修正を選んで確かめる

今ある個数へ、同じイベント内で2回足すには?

比較している処理

// 直接更新:このクリックで見えている個数を使う
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に使います。

  1. 左右それぞれの「Props」に「型を復習」と入力します。
  2. 順序を反転したとき、メモがPropsと一緒に移動するか予想します。
  3. 「教材の順序を反転」を押して、EffectとPropsの入力欄を比べます。

key = 配列のindex

key = 教材のID

現在の順序:Props → State → Effect

修正を選んで確かめる

並べ替え後も、教材と行のstateを対応させるには?

比較している処理

// 位置に対応する行の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:最後に検索した人と、表示される人が違う

りこの検索を始め、結果を待たずにたけるへ切り替えます。たけるの応答が先に到着し、その後でりこの応答が来たら、画面には誰が残るでしょう。

  1. 「りこを検索」「たけるを検索」を順に押します。
  2. 「たけるの応答を届ける」を先に押します。
  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エラー・取得中の表示・入力値の検証も扱います。

    結果と理由を確認する

    修正前は、後から届いたりこの結果で上書きされます。修正後は、現在の検索対象であるたけるの結果を保ちます。実験をリセットし、今度はりこの応答を先に届けると、修正後はたけるの応答が来るまで取得中のままです。

    次の課題

    「たけるの取得中に、りこの情報が一瞬でも表示されてよいか」を考えてください。結果を無視する処理と、前の表示を消す処理は別の役割です。

    エラー処理を含むuseFetchの実装へ → · React公式:Effectによるデータ取得

    実行している教材コード

    以下はこの画面で使用するコンポーネントのソースです。表示用に書き写した別コードではなく、実装ファイルから直接読み込んでいます。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の可用性を検証する教材ではありません。