รีวิวเคสจริง 2: แดชบอร์ดที่มีการค้นหา transition และข้อมูลแบบ streaming
ฟีเจอร์นี้คืออะไร
หัวข้อที่มีชื่อว่า “ฟีเจอร์นี้คืออะไร”โจทย์ที่ให้ AI คือ “เพิ่มแดชบอร์ดโปรเจกต์: กล่องค้นหาที่ใช้ use(), แผงแสดงเมตริกแบบสดที่รีเฟรชเมื่อโปรเจกต์ที่เลือกเปลี่ยน, แผงรายละเอียดต่อโปรเจกต์ที่มีช่องโน้ตกับ list รายการที่เกี่ยวข้อง และปุ่มรีเฟรชที่ห่อด้วย error boundary” ผลลัพธ์ด้านล่างคอมไพล์ผ่าน, tsc --noEmit ผ่านทุกไฟล์ และทุกส่วนก็ใช้แพทเทิร์นของ concurrent React ที่โมดูล Concurrent & Suspense ของคอร์สนี้สอนไปแล้ว อ่านตามลำดับการอ่านของโมดูลนี้ที่ปรับให้เข้ากับฟีเจอร์แบบ concurrent: hooks กับ dependency, effect เทียบกับ event, และสมมติฐานเรื่อง memo/compiler ล้วนโผล่มาที่นี่ — แค่สวมคราบ startTransition, use() และ useDeferredValue แทน useEffect ธรรมดา โค้ดนี้มี บั๊กจริง 6 จุด ซึ่งไม่มีจุดไหนเลยที่ tsc --noEmit จับได้
โค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้างให้”// src/dashboard/types.ts — as generatedexport type SearchResult = { id: string; title: string };export type Item = { id: string; title: string; related: string[] };// src/dashboard/search.ts — as generatedimport type { SearchResult } from './types';
export function fetchResults(query: string): Promise<SearchResult[]> { return new Promise((resolve) => { setTimeout(() => resolve([{ id: `${query}-1`, title: `Result for "${query}"` }]), 5); });}
export function logSearchAnalytics(query: string): Promise<void> { return Promise.resolve(void query);}// src/dashboard/SearchPanel.tsx — as generatedimport { Suspense, startTransition, use, useState } from 'react';import { fetchResults, logSearchAnalytics } from './search';
function SearchResultsList({ query }: { query: string }) { const results = use(fetchResults(query)); return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;}
export function SearchPanel() { const [query, setQuery] = useState(''); const [lastLogged, setLastLogged] = useState('');
function handleChange(next: string) { startTransition(async () => { setQuery(next); await logSearchAnalytics(next); setLastLogged(next); }); }
return ( <div> <input aria-label="search" onChange={(e) => handleChange(e.target.value)} /> <p>{lastLogged}</p> <Suspense fallback={<p>Searching…</p>}> <SearchResultsList query={query} /> </Suspense> </div> );}// src/dashboard/LiveMetrics.tsx — as generatedimport { useEffect, useState } from 'react';
export function fetchMetric(projectId: string): Promise<number> { const delayMs = projectId === 'slow' ? 30 : 5; return new Promise((resolve) => { setTimeout(() => resolve(projectId === 'slow' ? 111 : 222), delayMs); });}
export function LiveMetrics({ projectId }: { projectId: string }) { const [value, setValue] = useState<number | null>(null);
useEffect(() => { fetchMetric(projectId).then((result) => setValue(result)); }, [projectId]);
return <output>{value === null ? 'loading…' : value}</output>;}// src/dashboard/ItemDetail.tsx — as generatedimport { useDeferredValue, useState } from 'react';import type { Item } from './types';
export function ItemDetail({ item }: { item: Item }) { const [notes, setNotes] = useState(''); const deferredRelated = useDeferredValue(item.related);
return ( <div> <h3>{item.title}</h3> <textarea aria-label="notes" value={notes} onChange={(e) => setNotes(e.target.value)} /> <ul>{deferredRelated.map((r) => <li key={r}>{r}</li>)}</ul> </div> );}// src/dashboard/ErrorBoundary.tsx — as generatedimport { Component, type ReactNode } from 'react';
type State = { error: Error | null };
export class ErrorBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, State> { state: State = { error: null }; static getDerivedStateFromError(error: Error): State { return { error }; } render() { if (this.state.error) return this.props.fallback; return this.props.children; }}// src/dashboard/DangerZone.tsx — as generatedexport function refreshDashboardData(): void { throw new Error('refresh failed: rate limited');}
export function DangerZone() { return <button onClick={refreshDashboardData}>Refresh data</button>;}// src/dashboard/Dashboard.tsx — as generatedimport { useState } from 'react';import { SearchPanel } from './SearchPanel';import { LiveMetrics } from './LiveMetrics';import { ItemDetail } from './ItemDetail';import { ErrorBoundary } from './ErrorBoundary';import { DangerZone } from './DangerZone';import type { Item } from './types';
const items: Item[] = [ { id: 'p1', title: 'Checkout funnel', related: ['Cart', 'Payments'] }, { id: 'p2', title: 'Onboarding', related: ['Signup', 'Activation'] },];
export function Dashboard() { const [selectedId, setSelectedId] = useState(items[0].id); const selected = items.find((i) => i.id === selectedId)!;
return ( <div> <SearchPanel /> <LiveMetrics projectId={selectedId} /> <div role="group"> {items.map((item) => ( <button key={item.id} onClick={() => setSelectedId(item.id)}>{item.title}</button> ))} </div> <ItemDetail item={selected} /> <ErrorBoundary fallback={<p role="alert">Something went wrong.</p>}> <DangerZone /> </ErrorBoundary> </div> );}ไล่รีวิวทีละบั๊ก
หัวข้อที่มีชื่อว่า “ไล่รีวิวทีละบั๊ก”Hooks กับ dependency: use() ถูกเรียกด้วย promise ที่สร้างใหม่ทุกครั้งใน render
หัวข้อที่มีชื่อว่า “Hooks กับ dependency: use() ถูกเรียกด้วย promise ที่สร้างใหม่ทุกครั้งใน render”SearchResultsList เรียก fetchResults(query) ตรง ๆ ใน render body เลย
🐛 หา bug ในโค้ดนี้
function SearchResultsList({ query }) {const results = use(fetchResults(query)); // a NEW promise every renderreturn <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;}เฉลย
use() ไม่ได้ fetch หรือ cache อะไรเลย — มันแค่อ่าน promise ที่คุณส่งให้เท่านั้น การเรียก fetchResults(query) ตรง ๆ ใน render สร้าง promise ใหม่ทุก render จริง ๆ ทำให้ component suspend ค้างไม่มีที่สิ้นสุดหรือ refetch ทุก render ขึ้นอยู่กับจังหวะเวลา พิสูจน์ใน probe แล้ว: เรียก fetch ที่ไม่มี cache สองครั้งด้วย query เดียวกัน ได้ promise แยกกันสองตัว (เรียกจริงสองครั้ง) ในขณะที่เวอร์ชันที่มี cache คืน reference ของ promise ตัวเดียวกันทั้งสองครั้ง
✓ as generated: calling the fetch directly in render builds a NEW promise every single call✓ fixed: getResultsPromise caches by query — the SAME promise reference comes back
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือ cache promise ตาม query ไว้ใน Map เพื่อให้ render ซ้ำ ๆ (และการเรียก query เดิมซ้ำ ๆ) อ่าน promise ตัวเดียวกันที่กำลัง in-flight อยู่แล้ว
Effect เทียบกับ event: startTransition(async) เสียลำดับความสำคัญหลัง await
หัวข้อที่มีชื่อว่า “Effect เทียบกับ event: startTransition(async) เสียลำดับความสำคัญหลัง await”handleChange ห่อทุกอย่างด้วย startTransition(async () => {...}) แล้วเรียก setLastLogged(next) หลัง await logSearchAnalytics(next)
🐛 หา bug ในโค้ดนี้
function handleChange(next) {startTransition(async () => { setQuery(next); // before the first await — covered await logSearchAnalytics(next); setLastLogged(next); // after the await — NOT re-wrapped});}เฉลย
ส่วน troubleshooting ของ useTransition ใน react.dev บอกไว้ชัดเจนว่า มีแค่โค้ดที่อยู่ก่อน await ตัวแรกเท่านั้นที่นับเป็นส่วนหนึ่งของ transition โดยอัตโนมัติ setLastLogged(next) รันหลัง await และไม่ถูกห่อด้วย startTransition ของตัวเอง — มันเลยกลายเป็น update แบบเร่งด่วนธรรมดาไปเงียบ ๆ พิสูจน์ด้วยกลไกเดียวกับที่ update ที่ทำให้ suspend จะเจอจริง: setState ที่ทำให้ Suspense boundary suspend รันแบบเดียวกันทั้งแบบห่อใหม่และไม่ห่อใหม่ ถ้าไม่ห่อใหม่ boundary จะ fallback ไปหน้า loading ทันที ถ้าห่อใหม่ React จะคงเนื้อหาเดิมไว้บนจอระหว่างที่เนื้อหาใหม่โหลดอยู่เบื้องหลัง — ตรงตามสัญญาที่ isPending บอกไว้แต่บั๊กนี้แอบทำลายไปเงียบ ๆ
✓ as generated: a setQuery after a bare await is NOT part of the transition — Suspense falls back immediately✓ fixed: re-wrapping setQuery after await keeps it part of the transition — old content stays, no fallback
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือห่อ startTransition(() => setLastLogged(next)) รอบ ๆ การเรียกหลัง await อีกชั้นหนึ่ง
Effect เทียบกับ event: effect fetch ที่ไม่มีการ abort/จัดการ race
หัวข้อที่มีชื่อว่า “Effect เทียบกับ event: effect fetch ที่ไม่มีการ abort/จัดการ race”LiveMetrics fetch เมตริกตาม projectId ใน useEffect โดยไม่มีการยกเลิกใด ๆ เลย
🐛 หา bug ในโค้ดนี้
function LiveMetrics({ projectId }) {const [value, setValue] = useState(null);useEffect(() => { fetchMetric(projectId).then((result) => setValue(result));}, [projectId]);return <output>{value === null ? 'loading…' : value}</output>;}เฉลย
ถ้า projectId เปลี่ยนเร็ว (คลิกสลับโปรเจกต์ไปมา) request ที่เก่ากว่าและช้ากว่าอาจ resolve หลัง request ที่ใหม่กว่าและเร็วกว่า แล้วเขียนทับ state ที่สดใหม่ด้วยข้อมูลเก่า พิสูจน์ใน probe ด้วยคู่ request ที่ตั้งใจให้ช้าแล้วเร็ว (ตัวช้าเริ่มก่อนแต่จะ resolve ทีหลัง): สลับจากโปรเจกต์ที่ช้าไปโปรเจกต์ที่เร็ว ทำให้แผงยังแสดงค่าของโปรเจกต์ที่ช้าและเก่าแล้วอยู่
✓ as generated: a slow, stale response overwrites a newer, faster one✓ fixed: LiveMetrics aborts the previous request, so the fast response always wins
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือ AbortController ที่สร้างในตัว effect แล้วยกเลิกใน cleanup — request เดิมของ projectId ก่อนหน้าจะถูกยกเลิกทันทีที่ projectId เปลี่ยนอีกครั้ง
สมมติฐานเรื่อง memo/compiler: useDeferredValue ที่ไม่มี initialValue
หัวข้อที่มีชื่อว่า “สมมติฐานเรื่อง memo/compiler: useDeferredValue ที่ไม่มี initialValue”ItemDetail defer item.related ด้วย useDeferredValue(item.related) — ไม่มี argument ที่สอง
🐛 หา bug ในโค้ดนี้
const deferredRelated = useDeferredValue(item.related);เฉลย
ตั้งแต่ React 19 useDeferredValue รับ initialValue แบบไม่บังคับ ซึ่งใช้แค่ตอน render แรกของ component เท่านั้น react.dev บอกไว้ชัดเจนว่า ถ้าไม่มี ตัวนี้ “hook จะไม่ defer ระหว่าง render แรกเลย” — render แรกจะได้ค่าจริงตรง ๆ ไม่มีค่า placeholder ที่ถูก defer เลย พิสูจน์ใน probe แล้ว: render โดยไม่มี initialValue แสดง list รายการที่เกี่ยวข้องแบบเต็ม ๆ จริง ๆ ตั้งแต่ render แรกเลย (ไม่มีการ defer เกิดขึ้น); render useDeferredValue(related, []) แสดง placeholder ว่างเปล่าใน render แรกเดียวกันนั้นแทน
✓ as generated: useDeferredValue(related) with no initialValue shows the REAL list on the first render✓ fixed: ItemDetail(useDeferredValue(related, [])) shows the empty placeholder on the first render
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือ useDeferredValue(item.related, []) — placeholder ที่ถูกกำหนดไว้ชัดเจนและถูกสำหรับ render แรก ทันทีหลังจากที่ remount จาก key-reset ด้านล่างทำให้ component นี้เป็น instance ใหม่
Key กับ identity: ไม่มี key บน per-item subtree — โน้ตร่างหลุดข้ามรายการ
หัวข้อที่มีชื่อว่า “Key กับ identity: ไม่มี key บน per-item subtree — โน้ตร่างหลุดข้ามรายการ”Dashboard render <ItemDetail item={selected} /> โดยไม่มี key เลย — การสลับ selectedId จึง re-render ItemDetail instanceตัวเดิมในที่เดิม
🐛 หา bug ในโค้ดนี้
<ItemDetail item={selected} />เฉลย
ItemDetail มี state ของตัวเอง (notes) ซึ่งไม่เกี่ยวอะไรกับ item.title/item.related เลย — React ไม่มีทางรู้ว่าควร reset มันหรือเปล่า ถ้าไม่มี key การสลับจากรายการหนึ่งไปอีกรายการก็แค่อัปเดต prop บน component instanceตัวเดิมเท่านั้น notes — ซึ่งเป็น state ที่ React ไม่มีเหตุผลจะไปแตะ — เลยแค่ตกทอดต่อไปเฉย ๆ พิสูจน์ใน probe แล้ว: พิมพ์โน้ตร่างตอนดูรายการ A สลับไปรายการ B แล้ว textarea ยังมีข้อความร่างตัวเดิมอยู่ ตอนนี้ผูกอยู่กับรายการที่ผิดไปแล้ว
✓ as generated: without a key, switching items leaves the PREVIOUS notes draft on screen✓ fixed: key={item.id} forces a clean remount — the notes draft resets per item
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือ key={selected.id} บน ItemDetail — กลไกเดียวกับ reset switch ที่บทเรียน Rendering & Reconciliation ของคอร์สนี้พิสูจน์ไว้: key ที่เปลี่ยนบังคับให้เกิด unmount + remount แบบสะอาด reset state ทุกอย่างของ subtree นั้นโดยตั้งใจ
Effect เทียบกับ event: error boundary ไม่จับ throw ที่เกิดใน event handler
หัวข้อที่มีชื่อว่า “Effect เทียบกับ event: error boundary ไม่จับ throw ที่เกิดใน event handler”ปุ่มของ DangerZone เรียก refreshDashboardData ตรง ๆ เป็น onClick โดยพึ่งพา ErrorBoundary ที่ห่ออยู่ใน Dashboard
🐛 หา bug ในโค้ดนี้
export function DangerZone() {return <button onClick={refreshDashboardData}>Refresh data</button>;}เฉลย
error boundary จับ error ที่ throw ระหว่างการ render (และใน lifecycle method) ของ component ที่อยู่ใต้มันเท่านั้น — ไม่เคยจับใน event handler เลย เพราะ event handler เป็นแค่ callback ของ JS ธรรมดาที่รันนอก render ของ React พิสูจน์ใน probe แล้ว: คลิกปุ่มที่ throw ตรง ๆ ใน onClick ซึ่งห่อด้วย error boundary จริง ไม่เคยแสดง fallback ของ boundary เลย — exception หลุดไปถึง global error event ของ browser เอง (message: "handler-boom") ข้าม getDerivedStateFromError ไปเลย
✓ as generated: a throw inside an onClick handler is NOT caught by the error boundary✓ fixed: DangerZone catches the same throw itself with try/catch and shows a local error
Test Files 1 passed (1) Tests 2 passed (2)วิธีแก้คือจัดการ throw ตรงที่มันเกิดขึ้นจริง ๆ: try/catch ธรรมดาในตัว event handler เอง พร้อม local error state — error boundary ไม่มีทางเห็นมันอยู่แล้ว
โค้ดที่แก้ไขแล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้ไขแล้ว”export type SearchResult = { id: string; title: string };export type Item = { id: string; title: string; related: string[] };import type { SearchResult } from './types';
export function fetchResults(query: string): Promise<SearchResult[]> { return new Promise((resolve) => { setTimeout(() => resolve([{ id: `${query}-1`, title: `Result for "${query}"` }]), 5); });}
export function logSearchAnalytics(query: string): Promise<void> { return Promise.resolve(void query);}
const cache = new Map<string, Promise<SearchResult[]>>();
// FIX (use() reading a stable promise): cached per query, never a fresh// promise on every render — use() only reads a promise, it never caches one.export function getResultsPromise(query: string): Promise<SearchResult[]> { if (!query) return Promise.resolve([]); if (!cache.has(query)) cache.set(query, fetchResults(query)); return cache.get(query)!;}import { Suspense, startTransition, use, useState } from 'react';import { getResultsPromise, logSearchAnalytics } from './search';
function SearchResultsList({ query }: { query: string }) { const results = use(getResultsPromise(query)); return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;}
export function SearchPanel() { const [query, setQuery] = useState(''); const [lastLogged, setLastLogged] = useState('');
function handleChange(next: string) { startTransition(async () => { setQuery(next); // before the first await — genuinely part of the transition await logSearchAnalytics(next); // FIX (async transition pitfall): re-wrapped, so this stays low-priority // instead of silently becoming an urgent update. startTransition(() => setLastLogged(next)); }); }
return ( <div> <input aria-label="search" onChange={(e) => handleChange(e.target.value)} /> <p data-testid="last-logged">{lastLogged}</p> <Suspense fallback={<p data-testid="search-fallback">Searching…</p>}> <SearchResultsList query={query} /> </Suspense> </div> );}import { useEffect, useState } from 'react';
// A tiny simulated backend, deliberately slower for one id, so a real// out-of-order response race can be reproduced deterministically in a test.export function fetchMetric(projectId: string, signal: AbortSignal): Promise<number> { const delayMs = projectId === 'slow' ? 30 : 5; return new Promise((resolve, reject) => { const id = setTimeout(() => resolve(projectId === 'slow' ? 111 : 222), delayMs); signal.addEventListener('abort', () => { clearTimeout(id); reject(new DOMException('aborted', 'AbortError')); }); });}
// FIX (effect fetch race): an AbortController cancels the in-flight request// for the PREVIOUS projectId, so a slow, stale response can never overwrite// a newer, faster one.export function LiveMetrics({ projectId }: { projectId: string }) { const [value, setValue] = useState<number | null>(null);
useEffect(() => { const controller = new AbortController(); setValue(null); fetchMetric(projectId, controller.signal) .then((result) => setValue(result)) .catch((err: Error) => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [projectId]);
return <output data-testid="metric">{value === null ? 'loading…' : value}</output>;}import { useDeferredValue, useState } from 'react';import type { Item } from './types';
// FIX (useDeferredValue misuse): initialValue is shown on this component's// very first render — the render right after the parent's key reset — so an// expensive related-items list never blocks that first paint.export function ItemDetail({ item }: { item: Item }) { const [notes, setNotes] = useState(''); // FIX (missing key reset): resets // per item ONLY because the parent renders this with key={item.id} — see Dashboard.tsx. const deferredRelated = useDeferredValue(item.related, [] as string[]);
return ( <div> <h3>{item.title}</h3> <textarea aria-label="notes" value={notes} onChange={(e) => setNotes(e.target.value)} /> <ul data-testid="related">{deferredRelated.map((r) => <li key={r}>{r}</li>)}</ul> </div> );}import { Component, type ReactNode } from 'react';
type State = { error: Error | null };
export class ErrorBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, State> { state: State = { error: null };
static getDerivedStateFromError(error: Error): State { return { error }; }
render() { if (this.state.error) return this.props.fallback; return this.props.children; }}import { useState } from 'react';
// A tiny simulated backend call that always fails, standing in for a real// rate-limited/expired-session refresh — deterministic enough for a test.export function refreshDashboardData(): void { throw new Error('refresh failed: rate limited');}
// FIX (error boundaries don't catch event-handler throws): handle the throw// with a plain try/catch and local error state — an ErrorBoundary above this// component would never see it, because it never renders while throwing.export function DangerZone() { const [error, setError] = useState<string | null>(null);
function handleRefresh() { try { refreshDashboardData(); } catch (err) { setError((err as Error).message); } }
return ( <div> <button onClick={handleRefresh}>Refresh data</button> {error && <p role="alert">{error}</p>} </div> );}import { useState } from 'react';import { SearchPanel } from './SearchPanel';import { LiveMetrics } from './LiveMetrics';import { ItemDetail } from './ItemDetail';import { ErrorBoundary } from './ErrorBoundary';import { DangerZone } from './DangerZone';import type { Item } from './types';
const items: Item[] = [ { id: 'p1', title: 'Checkout funnel', related: ['Cart', 'Payments'] }, { id: 'p2', title: 'Onboarding', related: ['Signup', 'Activation'] },];
export function Dashboard() { const [selectedId, setSelectedId] = useState(items[0].id); const selected = items.find((i) => i.id === selectedId)!;
return ( <div> <SearchPanel /> <LiveMetrics projectId={selectedId} /> <div role="group"> {items.map((item) => ( <button key={item.id} onClick={() => setSelectedId(item.id)}>{item.title}</button> ))} </div> {/* FIX (missing key reset): key={selected.id} forces a clean remount — and a fresh `notes` draft — every time the selected item changes. */} <ItemDetail key={selected.id} item={selected} /> <ErrorBoundary fallback={<p role="alert">Something went wrong.</p>}> <DangerZone /> </ErrorBoundary> </div> );}import { Component, Suspense, startTransition, use, useDeferredValue, useEffect, useState, type ReactNode } from 'react';import { act, render, screen, cleanup, fireEvent } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import { getResultsPromise } from '../dashboard/search';import { LiveMetrics, fetchMetric } from '../dashboard/LiveMetrics';import { ItemDetail } from '../dashboard/ItemDetail';import { ErrorBoundary } from '../dashboard/ErrorBoundary';import { DangerZone } from '../dashboard/DangerZone';import type { Item } from '../dashboard/types';
afterEach(cleanup);
let fetchCallsBad = 0;function getResultsPromiseBad(query: string) { fetchCallsBad++; return Promise.resolve([{ id: '1', title: query }]);}
test('as generated: calling the fetch directly in render builds a NEW promise every single call', () => { fetchCallsBad = 0; getResultsPromiseBad('react'); getResultsPromiseBad('react'); expect(fetchCallsBad).toBe(2);});
test('fixed: getResultsPromise caches by query — the SAME promise reference comes back', () => { const p1 = getResultsPromise('react'); const p2 = getResultsPromise('react'); expect(p1).toBe(p2);});
function makeResource() { const cache = new Map<string, Promise<string>>(); return (key: string) => { if (!cache.has(key)) cache.set(key, new Promise((resolve) => setTimeout(() => resolve(`data:${key}`), 10))); return cache.get(key)!; };}
function SearchContent({ resource, query }: { resource: (k: string) => Promise<string>; query: string }) { const data = use(resource(query)); return <p data-testid="content">{data}</p>;}
function SearchDemo({ rewrap }: { rewrap: boolean }) { const resource = useState(() => makeResource())[0]; const [query, setQuery] = useState('a'); function runSearch() { startTransition(async () => { await Promise.resolve().then(() => Promise.resolve()); if (rewrap) { startTransition(() => setQuery('b')); } else { setQuery('b'); } }); } return ( <div> <button onClick={runSearch}>search</button> <Suspense fallback={<p data-testid="fallback">loading…</p>}> <SearchContent resource={resource} query={query} /> </Suspense> </div> );}
test('as generated: a setQuery after a bare await is NOT part of the transition — Suspense falls back immediately', async () => { await act(async () => { render(<SearchDemo rewrap={false} />); }); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('content').textContent).toBe('data:a');
await act(async () => { screen.getByText('search').click(); await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId('fallback')).toBeTruthy();});
test('fixed: re-wrapping setQuery after await keeps it part of the transition — old content stays, no fallback', async () => { await act(async () => { render(<SearchDemo rewrap={true} />); }); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('content').textContent).toBe('data:a');
await act(async () => { screen.getByText('search').click(); await Promise.resolve(); await Promise.resolve(); }); expect(screen.queryByTestId('fallback')).toBeNull(); expect(screen.getByTestId('content').textContent).toBe('data:a');});
function LiveMetricsBad({ projectId }: { projectId: string }) { const [value, setValue] = useState<number | null>(null); useEffect(() => { fetchMetric(projectId, new AbortController().signal).then((result) => setValue(result)); }, [projectId]); return <output data-testid="metric-bad">{value === null ? 'loading…' : value}</output>;}
test('as generated: a slow, stale response overwrites a newer, faster one', async () => { const { rerender } = render(<LiveMetricsBad projectId="slow" />); rerender(<LiveMetricsBad projectId="fast" />); await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); expect(screen.getByTestId('metric-bad').textContent).toBe('111');});
test('fixed: LiveMetrics aborts the previous request, so the fast response always wins', async () => { const { rerender } = render(<LiveMetrics projectId="slow" />); rerender(<LiveMetrics projectId="fast" />); await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); expect(screen.getByTestId('metric').textContent).toBe('222');});
class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) return <p data-testid="boundary-fallback">caught</p>; return this.props.children; }}
function ThrowingButtonBad() { function handleClick() { throw new Error('handler-boom'); } return <button onClick={handleClick}>go</button>;}
test('as generated: a throw inside an onClick handler is NOT caught by the error boundary', async () => { render( <Boundary> <ThrowingButtonBad /> </Boundary>, ); const onWindowError = vi.fn((e: ErrorEvent) => { e.preventDefault(); }); window.addEventListener('error', onWindowError); fireEvent.click(screen.getByText('go')); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); window.removeEventListener('error', onWindowError);
expect(onWindowError).toHaveBeenCalled(); expect(String(onWindowError.mock.calls[0][0].error?.message ?? onWindowError.mock.calls[0][0].message)).toContain('handler-boom'); expect(screen.queryByTestId('boundary-fallback')).toBeNull();});
test('fixed: DangerZone catches the same throw itself with try/catch and shows a local error', () => { render( <ErrorBoundary fallback={<p role="alert">boundary fallback</p>}> <DangerZone /> </ErrorBoundary>, ); fireEvent.click(screen.getByText('Refresh data')); expect(screen.getByRole('alert').textContent).toBe('refresh failed: rate limited');});
test('as generated: useDeferredValue(related) with no initialValue shows the REAL list on the first render', () => { const seen: string[][] = []; function RelatedBad({ related }: { related: string[] }) { const deferred = useDeferredValue(related); seen.push(deferred); return null; } render(<RelatedBad related={['Cart', 'Payments']} />); expect(seen[0]).toEqual(['Cart', 'Payments']);});
test('fixed: ItemDetail(useDeferredValue(related, [])) shows the empty placeholder on the first render', () => { const seen: string[][] = []; function RelatedFixed({ related }: { related: string[] }) { const deferred = useDeferredValue(related, [] as string[]); seen.push(deferred); return null; } render(<RelatedFixed related={['Cart', 'Payments']} />); expect(seen[0]).toEqual([]);});
test('sanity: ItemDetail renders the related list once React settles past the first render', () => { const item: Item = { id: 'p1', title: 'Checkout', related: ['Cart', 'Payments'] }; render(<ItemDetail item={item} />); expect(screen.getByTestId('related').textContent).toContain('Cart');});
test('as generated: without a key, switching items leaves the PREVIOUS notes draft on screen', () => { const a: Item = { id: 'a', title: 'A', related: [] }; const b: Item = { id: 'b', title: 'B', related: [] }; const { getByLabelText, rerender } = render(<ItemDetail item={a} />); fireEvent.change(getByLabelText('notes'), { target: { value: 'draft for A' } }); rerender(<ItemDetail item={b} />); expect((getByLabelText('notes') as HTMLTextAreaElement).value).toBe('draft for A');});
test('fixed: key={item.id} forces a clean remount — the notes draft resets per item', () => { const a: Item = { id: 'a', title: 'A', related: [] }; const b: Item = { id: 'b', title: 'B', related: [] }; const { getByLabelText, rerender } = render(<ItemDetail key={a.id} item={a} />); fireEvent.change(getByLabelText('notes'), { target: { value: 'draft for A' } }); rerender(<ItemDetail key={b.id} item={b} />); expect((getByLabelText('notes') as HTMLTextAreaElement).value).toBe('');});Type-check ผ่านสะอาดกับ tsc --noEmit (strict, React 19 types) และรันผ่าน test ไฟล์ด้านบนได้จริง:
✓ as generated: calling the fetch directly in render builds a NEW promise every single call✓ fixed: getResultsPromise caches by query — the SAME promise reference comes back✓ as generated: a setQuery after a bare await is NOT part of the transition — Suspense falls back immediately✓ fixed: re-wrapping setQuery after await keeps it part of the transition — old content stays, no fallback✓ as generated: a slow, stale response overwrites a newer, faster one✓ fixed: LiveMetrics aborts the previous request, so the fast response always wins✓ as generated: a throw inside an onClick handler is NOT caught by the error boundary✓ fixed: DangerZone catches the same throw itself with try/catch and shows a local error✓ as generated: useDeferredValue(related) with no initialValue shows the REAL list on the first render✓ fixed: ItemDetail(useDeferredValue(related, [])) shows the empty placeholder on the first render✓ sanity: ItemDetail renders the related list once React settles past the first render✓ as generated: without a key, switching items leaves the PREVIOUS notes draft on screen✓ fixed: key={item.id} forces a clean remount — the notes draft resets per item
Test Files 1 passed (1) Tests 13 passed (13)สิ่งที่เปลี่ยนและเหตุผล
หัวข้อที่มีชื่อว่า “สิ่งที่เปลี่ยนและเหตุผล”| # | บั๊ก | หมวด | วิธีแก้ |
|---|---|---|---|
| 1 | use(fetchResults(query)) — promise ใหม่ทุก render | Hooks & deps | cache promise ตาม query ไว้ใน Map |
| 2 | setLastLogged(next) ถูกเรียกหลัง await ใน startTransition(async () => {...}) | Effects vs events | ห่อการเรียกหลัง await ด้วย startTransition ของตัวเองอีกชั้น |
| 3 | effect fetch ตาม projectId โดยไม่มีการยกเลิก | Effects vs events | AbortController ที่ยกเลิกใน cleanup |
| 4 | useDeferredValue(item.related) — ไม่มี initialValue | Memo/compiler assumptions | useDeferredValue(item.related, []) |
| 5 | <ItemDetail item={selected} /> — ไม่มี key | Keys & identity | key={selected.id} บังคับ remount แบบสะอาดต่อรายการ |
| 6 | onClick={refreshDashboardData} — พึ่งพา ErrorBoundary ที่อยู่เหนือมัน | Effects vs events | try/catch ในตัว event handler เอง |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ฟีเจอร์ concurrent ของ React (startTransition, use(), useDeferredValue) ยังคงสร้างอยู่บน primitive เดียวกับที่โมดูล Hooks ของคอร์สนี้สอนไปแล้ว — dependency array, การเปรียบเทียบ reference, state ของ component instance ของตัวเอง — มันแค่เอา primitive เหล่านั้นไปใช้กับเมื่อไรและลำดับไหนที่งานจะเกิดขึ้น ไม่ใช่แค่อะไรที่จะถูก render บั๊กทุกจุดในรีวิวนี้คือรูปแบบความล้มเหลวที่คุ้นเคยแบบเดียวกัน (ค่าที่ค้าง, reference ที่เปลี่ยนทั้งที่ไม่ควรเปลี่ยน, state ที่ควร reset) แค่สวมคราบของ concurrent API เท่านั้นเอง
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
startTransition(async () => {...})ทำให้ callback ทั้งก้อนกลายเป็น low-priority” มีแค่โค้ดก่อนawaitตัวแรกเท่านั้นที่นับเป็นส่วนหนึ่งของ transition โดยอัตโนมัติ — พิสูจน์ไปแล้วสองครั้งด้านบน (ทั้งห่อใหม่และไม่ห่อใหม่) กับ Suspense boundary จริง - “error boundary ที่ห่อ component ป้องกันทุกอย่างที่ component นั้นทำ” มันป้องกันแค่ error ตอน render ของ subtree นั้น — ไม่ใช่ click handler ไม่ใช่ promise ที่ reject นอก
use()และไม่ใช่โค้ดของตัว boundary เอง - “ถ้า prop ของ component ไม่เปลี่ยน state ภายในของมันก็ปลอดภัย” state
notesของItemDetailไม่เกี่ยวอะไรกับitem.related/item.titleเลย — parent ต้อง opt-in การ reset ด้วยkeyเอง ไม่งั้น state ที่ควรเป็นของแต่ละรายการก็จะตกทอดต่อไปเงียบ ๆ
flowchart TD A["1. Hooks and their dependencies"] --> A1["use() with a promise built in render"] B["2. Effects vs events"] --> B1["startTransition(async) after await"] B --> B2["effect fetch with no abort"] B --> B3["error boundary vs event-handler throw"] C["3. Memo/compiler assumptions"] --> C1["useDeferredValue with no initialValue"] D["4. Keys and identity"] --> D1["missing key on a per-item subtree"]