Actions & useActionState
pattern ที่ React 19 ลบทิ้ง
หัวข้อที่มีชื่อว่า “pattern ที่ React 19 ลบทิ้ง”ก่อนมี Actions การ submit form หมายถึงต้อง wire state สามชิ้นด้วยมือ และต้องจำอัปเดตทุกตัว:
// The old way — lots of manual bookkeeping.function ChangeName() { const [name, setName] = useState(''); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null);
async function handleSubmit(e) { e.preventDefault(); setIsPending(true); setError(null); try { await updateName(name); } catch (err) { setError(err.message); } finally { setIsPending(false); // must not forget this } } // ...form wired to handleSubmit}async form ทุกอันในแอปทำซ้ำแบบนี้: loading flag, error slot, try/catch/finally ทั้งหมดนี้คือ boilerplate และถ้าลืม finally spinner จะค้างตลอดกาล
Action: async function ที่ส่งให้ form
หัวข้อที่มีชื่อว่า “Action: async function ที่ส่งให้ form”Action ก็แค่ async function ที่คุณส่งให้ <form> ผ่าน prop action React เรียก function นี้ตอน submit, ส่ง FormData ให้ และจัดการ pending state กับการ reset form ให้:
<form action={async (formData) => { await updateName(formData.get('name'));}}> <input name="name" /> <button type="submit">Update</button></form>ไม่ต้อง onSubmit, ไม่ต้อง preventDefault, ไม่ต้องประกอบ FormData เอง แต่คุณยังต้องการ state ของ pending และ error เพื่อแสดงให้ user เห็นว่าเกิดอะไรขึ้น — นั่นคือสิ่งที่ useActionState เพิ่มเข้ามา
useActionState: pending, error และ result ใน call เดียว
หัวข้อที่มีชื่อว่า “useActionState: pending, error และ result ใน call เดียว”useActionState(action, initialState) ห่อ action ของคุณและคืน tuple สามตัว: state ปัจจุบัน, action ที่ห่อแล้วสำหรับส่งให้ form และ boolean pending
import { useActionState } from 'react';
function ChangeName() { const [error, submitAction, isPending] = useActionState( async (previousState, formData) => { const error = await updateName(formData.get('name')); if (error) { return error; // returned value becomes the new state } redirect('/profile'); return null; // success: clear the error }, null, // initial state );
return ( <form action={submitAction}> <input type="text" name="name" /> <button type="submit" disabled={isPending}>Update</button> {error && <p>{error}</p>} </form> );}อ่าน shape ให้ดี ๆ — นี่คือส่วนที่คนมักทำผิด:
- action รับ
(previousState, formData)และ return state ถัดไป useActionStateคืน[state, formAction, isPending]—stateคือค่าที่ action คุณ return ล่าสุด,formActionคือสิ่งที่ส่งให้<form action={...}>และisPendingถูกจัดการให้
sequenceDiagram participant U as User participant F as form action participant A as your async action U->>F: submit F->>F: isPending = true F->>A: action(prevState, formData) A-->>F: returns next state F->>F: isPending = false, state updated F->>U: re-render with new state
ทั้งการเต้นสามจังหวะ — loading flag, error slot, try/catch/finally — ยุบเหลือ hook เดียวที่ลืม reset spinner ไม่ได้
กับดัก startTransition(async () => {...})
หัวข้อที่มีชื่อว่า “กับดัก startTransition(async () => {...})”ลึกลงไป pending state ของ Action ถูก track เป็น Transition — นั่นคือเหตุผลที่ isPending แม่นยำโดยไม่ต้องจัดการ flag เอง แต่พอคุณเขียน async function ของตัวเองแล้วส่งให้ startTransition ตรง ๆ (pattern ที่พบบ่อยสำหรับ helper แบบ “ทำสิ่งนี้โดยไม่ block UI” ที่ไม่ใช่ form Action) คุณอาจเจอขอบคมที่ react.dev บอกไว้ตรง ๆ:
Any async calls that are awaited in the
actionwill be included in the Transition, but currently require wrapping anysetfunctions after theawaitin an additionalstartTransition. — react.dev/reference/react/useTransition
พูดง่าย ๆ: setState ที่เรียก ก่อน await ใน startTransition(async () => {...}) ยังอยู่ใน Transition; setState ที่เรียก หลัง await ไม่อยู่ใน Transition แล้ว เว้นแต่คุณจะห่อมันด้วย startTransition ซ้อนอีกชั้น React หลุด track async scope ข้าม await ไป — นี่เป็นข้อจำกัดของ JavaScript (ยังไม่มี AsyncContext) ไม่ใช่ bug ของ React
เรื่องนี้สำคัญจริงตอนมี Suspense เข้ามาเกี่ยวข้อง: update ที่เป็น Transition แล้ว suspend จะเก็บ content เดิมไว้บนจอจนกว่า data จะพร้อม; update ที่ไม่ใช่ Transition แล้ว suspend จะโชว์ fallback ทันที ดังนั้น setShow(true) ตัวเดียวกันเป๊ะ ๆ อาจเก็บ UI ให้นิ่งหรือกระพริบ loading state ก็ได้ ขึ้นอยู่กับว่ามันถูกห่อใหม่หลัง await หรือเปล่า:
import { describe, it, expect } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { startTransition, use, useState, Suspense } from 'react'
function makeResource() { let resolve!: () => void const promise = new Promise<void>((res) => { resolve = res }) return { promise, resolve }}
function Panel({ resource }: { resource: { promise: Promise<void> } }) { use(resource.promise) return <p>Loaded</p>}
function App({ rewrap }: { rewrap: boolean }) { const [show, setShow] = useState(false) const [resource] = useState(() => makeResource()) ;(globalThis as any).__resolve = resource.resolve
function handleClick() { startTransition(async () => { await Promise.resolve() // simulate an awaited async step if (rewrap) { // ✅ re-wrapped: this setState stays part of the Transition startTransition(() => setShow(true)) } else { // ❌ not re-wrapped: this setState is a normal, non-Transition update setShow(true) } }) }
return ( <> <button onClick={handleClick}>go</button> <Suspense fallback={<p>Loading fallback</p>}> {show ? <Panel resource={resource} /> : <p>Old content</p>} </Suspense> </> )}
describe('startTransition(async () => {...}) — setState after await', () => { it('WITHOUT re-wrapping: the update after await is not a Transition, so Suspense shows the fallback immediately', async () => { render(<App rewrap={false} />) fireEvent.click(screen.getByText('go')) await waitFor(() => expect(screen.getByText('Loading fallback')).toBeTruthy()) ;(globalThis as any).__resolve() await waitFor(() => expect(screen.getByText('Loaded')).toBeTruthy()) })
it('WITH re-wrapping: the update after await stays a Transition, so Suspense keeps the old content until ready', async () => { render(<App rewrap={true} />) fireEvent.click(screen.getByText('go')) // Give the microtask queue a chance to run; old content must still be there. await new Promise((r) => setTimeout(r, 20)) expect(screen.getByText('Old content')).toBeTruthy() expect(screen.queryByText('Loading fallback')).toBeNull() ;(globalThis as any).__resolve() await waitFor(() => expect(screen.getByText('Loaded')).toBeTruthy()) })})ทั้งสอง case ผ่านหมด — นั่นคือประเด็น บั๊กนี้ไม่ใช่ crash; มันคือ UX ที่แย่ลงซึ่ง “ดูถูกต้อง” ทุกครั้งที่ code review (compile ผ่าน, รันได้, data ก็มาแสดงในที่สุด) และจะโผล่ให้เห็นก็ตอนที่ user จริงเจอ network ช้าเท่านั้น
requestFormReset: reset form นอกเหนือจากการ submit
หัวข้อที่มีชื่อว่า “requestFormReset: reset form นอกเหนือจากการ submit”React auto-reset field แบบ uncontrolled ของ form หลัง Action submit สำเร็จอยู่แล้ว — แต่บางครั้งคุณต้อง reset form โดย ไม่ submit (ปุ่ม “Clear” หรือ reset ตาม event อื่น) requestFormReset(form) ทำแบบนั้นได้แบบ imperative เหมือน setter ของ useOptimistic มันเป็น API ที่ใช้ได้เฉพาะใน Action: เรียกนอก Transition/Action ก็ยังทำงาน แต่ React จะ log warning
import { describe, it, expect, vi, afterEach } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import { requestFormReset } from 'react-dom'import { startTransition, useRef } from 'react'
function ClearableForm({ insideTransition }: { insideTransition: boolean }) { const formRef = useRef<HTMLFormElement>(null) function clear() { if (insideTransition) { // ✅ requestFormReset is an Action-only API, like useOptimistic. startTransition(() => requestFormReset(formRef.current!)) } else { // ❌ called directly from an event handler. requestFormReset(formRef.current!) } } return ( <form ref={formRef}> <input name="title" defaultValue="draft title" /> <button type="button" onClick={clear}>Clear</button> </form> )}
describe('requestFormReset', () => { afterEach(() => vi.restoreAllMocks())
it('outside a Transition/Action: still resets the field, but logs a console.error', async () => { const errors: string[] = [] vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(' ')) }) render(<ClearableForm insideTransition={false} />) const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: 'something the user typed' } }) fireEvent.click(screen.getByText('Clear')) expect(input.value).toBe('draft title') await waitFor(() => expect(errors.length).toBeGreaterThan(0)) expect(errors.some((e) => e.includes('requestFormReset was called outside a transition or action'))).toBe(true) })
it('inside startTransition: resets the field with no warning', async () => { const errors: string[] = [] vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.map(String).join(' ')) }) render(<ClearableForm insideTransition={true} />) const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: 'something the user typed' } }) fireEvent.click(screen.getByText('Clear')) await waitFor(() => expect(input.value).toBe('draft title')) expect(errors.length).toBe(0) })})ทั้งสอง test ผ่านกับ React 19.3.0 จริง — รวมถึงข้อความ console ที่ตรงตัวอักษร จับได้จากการรันจริง: requestFormReset was called outside a transition or action. To fix, move the update to an action, or wrap with startTransition. ข้อความนี้ไม่มีอยู่ในเอกสาร prose ของ react.dev สำหรับ requestFormReset ตอนที่เขียนบทนี้ — เป็นรายละเอียดที่ได้มาจากการรันโค้ดจริงเท่านั้น ซึ่งเป็นเหตุผลที่คอร์สนี้ทำแบบนี้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”isPending ของ Action จริง ๆ แล้วคือ “มี Transition ที่ active ห่อ async work นี้อยู่ไหม” — นั่นคือเหตุผลที่ caveat เรื่อง async-Transition ของเอกสารสำคัญตรงนี้เป๊ะ ๆ อะไรที่อ่านหรือเขียน state หลัง await ใน startTransition ต้องมี startTransition ซ้อนของตัวเองถึงจะยังอยู่ใน Transition นั้น; อะไรก่อน await แรกครอบคลุมอยู่แล้ว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Pattern | ได้อะไร | ต้นทุน |
|---|---|---|
<form action={asyncFn}> + useActionState | pending/state ถูก track ให้ทั้งหมดโดย React | action signature ต้องเป็น (prevState, formData) ไม่ใช่ (formData) |
startTransition(async () => {...}) เขียนเอง | ควบคุมเต็มที่ว่าอะไรนับเป็น “pending” | ต้องห่อ set ทุกตัวหลัง await เองด้วยมือ ไม่งั้นหลุดจาก Transition แบบเงียบ ๆ |
requestFormReset(form) | reset แบบ imperative นอกเหนือจากการ submit | API ใช้ได้เฉพาะใน Action — เรียกนอก Transition/Action แล้ว React log warning ตามที่พิสูจน์ข้างบน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ทุกอย่างใน
startTransition(async () => {...})เป็นส่วนหนึ่งของ Transition” มีแค่ส่วน synchronous ก่อนawaitแรก บวกกับอะไรที่ห่อstartTransitionซ้อนใหม่หลังแต่ละawaitเท่านั้นที่นับจริง ๆ - “
requestFormResetก็แค่form.reset()ที่เปลี่ยนชื่อ” มันตั้งใจให้ใช้คู่กับ Action โดยเฉพาะ — เรียกนอก Action แล้วเกิด console warning จริงที่พิสูจน์ได้ ซึ่งform.reset()ธรรมดาไม่มีวันทำแบบนั้น - “ถ้า Transition ไม่ error แปลว่ามันทำงานตามที่คาดหวัง” กับดัก async-await ข้างบนไม่เคย throw หรือ log อะไรเองเลย — อาการเดียวคือ Suspense fallback กระพริบตรงที่ควรเก็บ content เดิมไว้ ความเงียบไม่ใช่หลักฐานว่าถูกต้องตรงนี้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- callback
startTransition(async () => {...})ที่ AI สร้าง อัปเดต state หลังawaitโดยไม่มีstartTransitionซ้อนไหม update นั้นหลุดจาก Transition แบบเงียบ ๆ — เช็คทุกsetที่อยู่หลังawaitใด ๆ ใน async function เดียวกัน requestFormResetถูกเรียกตรง ๆ จาก event handler หรือถูกห่อด้วยstartTransition/Action ทั้งคู่ “ทำงาน” แต่มีแค่แบบเดียวที่ไม่มี console warning- action ที่ส่งให้
useActionStateรับ(previousState, formData)จริงไหม ไม่ใช่แค่(formData)(สอนเต็มไปแล้วในหัวข้อก่อนหน้าของบทนี้ — ยังเป็นข้อผิดพลาดที่พบบ่อยที่สุดที่ต้องเช็คซ้ำ)
🐛 หา bug ในโค้ดนี้
function SearchResults() {const [query, setQuery] = useState('')const [results, setResults] = useState<string[]>([])
function handleChange(nextQuery: string) { startTransition(async () => { setQuery(nextQuery) const data = await fetchResults(nextQuery) setResults(data) // looks fine — it's inside startTransition, right? })}
return <input onChange={(e) => handleChange(e.target.value)} />}เฉลย
setResults(data) รันหลัง await fetchResults(nextQuery) ดังนั้นแม้จะอยู่ใน startTransition(async () => {...}) ตาม lexical scope แต่มัน ไม่ได้ เป็นส่วนหนึ่งของ Transition — react.dev บอกชัดว่า state update หลัง await ต้องมี startTransition ซ้อนของตัวเองถึงจะยังนับเป็น Transition ถ้า SearchResults render อะไรที่ suspend ตาม results โค้ดนี้จะโชว์ loading fallback แทนที่จะเก็บผลลัพธ์เดิมไว้บนจอ เหมือน test ที่พิสูจน์ไว้ข้างบนเป๊ะ ๆ วิธีแก้: ห่อบรรทัดนั้นด้วย startTransition(() => setResults(data))