ข้ามไปยังเนื้อหา

Actions & useActionState

ก่อนมี 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> ผ่าน 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(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
lifecycle ของ Action

ทั้งการเต้นสามจังหวะ — loading flag, error slot, try/catch/finally — ยุบเหลือ hook เดียวที่ลืม reset spinner ไม่ได้

ลึกลงไป 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 action will be included in the Transition, but currently require wrapping any set functions after the await in an additional startTransition. — 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 หรือเปล่า:

src/__tests__/transition-async-await.test.tsx
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 ช้าเท่านั้น

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

src/__tests__/request-form-reset.test.tsx
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 ตอนที่เขียนบทนี้ — เป็นรายละเอียดที่ได้มาจากการรันโค้ดจริงเท่านั้น ซึ่งเป็นเหตุผลที่คอร์สนี้ทำแบบนี้

isPending ของ Action จริง ๆ แล้วคือ “มี Transition ที่ active ห่อ async work นี้อยู่ไหม” — นั่นคือเหตุผลที่ caveat เรื่อง async-Transition ของเอกสารสำคัญตรงนี้เป๊ะ ๆ อะไรที่อ่านหรือเขียน state หลัง await ใน startTransition ต้องมี startTransition ซ้อนของตัวเองถึงจะยังอยู่ใน Transition นั้น; อะไรก่อน await แรกครอบคลุมอยู่แล้ว

Patternได้อะไรต้นทุน
<form action={asyncFn}> + useActionStatepending/state ถูก track ให้ทั้งหมดโดย Reactaction signature ต้องเป็น (prevState, formData) ไม่ใช่ (formData)
startTransition(async () => {...}) เขียนเองควบคุมเต็มที่ว่าอะไรนับเป็น “pending”ต้องห่อ set ทุกตัวหลัง await เองด้วยมือ ไม่งั้นหลุดจาก Transition แบบเงียบ ๆ
requestFormReset(form)reset แบบ imperative นอกเหนือจากการ submitAPI ใช้ได้เฉพาะใน 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 เดิมไว้ ความเงียบไม่ใช่หลักฐานว่าถูกต้องตรงนี้
  • 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))

"Action" ของ React 19 คืออะไร?
useActionState(action, initialState) คืนอะไร?
action ที่ส่งให้ useActionState รับ argument อะไร?
useActionState แทนที่ boilerplate อะไร?
ตาม react.dev คุณต้องทำอะไรกับ set ที่เรียก หลัง await ใน startTransition(async () => {...})?
setResults(data) ตรงนี้เป็นส่วนหนึ่งของ Transition ไหม?
function SearchResults() {
  function handleChange(nextQuery: string) {
    startTransition(async () => {
      setQuery(nextQuery)
      const data = await fetchResults(nextQuery)
      setResults(data)
    })
  }
  // ...
}
เกิดอะไรขึ้นเมื่อเรียก requestFormReset(form) จาก event handler ธรรมดา นอก Transition หรือ Action?
คำอธิบายที่ตรงที่สุดว่าปุ่มนี้ทำอะไร?
function clear() {
  requestFormReset(formRef.current)
}

return <button type="button" onClick={clear}>Clear</button>