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

Forms & useOptimistic

บ่อยครั้งคุณอยากให้ submit button รู้ว่า form ของตัวเอง pending อยู่ไหม — เพื่อ disable ตัวเองและแสดง spinner การส่ง isPending ลงไปทุก component ระหว่างทางน่าเบื่อ useFormStatus (จาก react-dom) ให้ component อ่าน status ของ <form> ที่เป็น parent ที่ใกล้ที่สุดได้ตรง ๆ:

import { useFormStatus } from 'react-dom';
// This button reads the enclosing form's status — no props needed.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save'}
</button>
);
}
function ProfileForm({ action }) {
return (
<form action={action}>
<input name="name" />
<SubmitButton /> {/* knows the form is pending, with zero prop drilling */}
</form>
);
}

กฎเดียว: useFormStatus อ่าน form ที่เป็น parent ดังนั้น SubmitButton ต้องถูก render อยู่ข้างใน <form> ไม่ใช่เป็น component ที่ render form เอง react.dev ระบุไว้ตรง ๆ:

useFormStatus will only return status information for a parent <form>. It will not return status information for any <form> rendered in that same component or children components. — react.dev/reference/react-dom/hooks/useFormStatus

กฎนี้ผิดได้ง่ายโดยไม่มี error ใด ๆ เพราะทั้งเวอร์ชันผิดและถูกต่าง compile และ render ได้ปกติทั้งคู่ — ความต่างโผล่มาเฉพาะตอนที่ submission กำลังทำงานอยู่จริงเท่านั้น:

src/__tests__/use-form-status-placement.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { useFormStatus } from 'react-dom'
function slowSave(_formData: FormData) {
return new Promise<void>((resolve) => setTimeout(resolve, 60))
}
// ❌ useFormStatus called in the SAME component that renders <form>.
function FormWrong() {
const { pending } = useFormStatus()
return (
<form action={slowSave}>
<button type="submit">{pending ? 'Saving (wrong)...' : 'Save (wrong)'}</button>
</form>
)
}
// ✅ useFormStatus called from a child of <form>.
function SubmitStatus() {
const { pending } = useFormStatus()
return <button type="submit">{pending ? 'Saving (right)...' : 'Save (right)'}</button>
}
function FormRight() {
return (
<form action={slowSave}>
<SubmitStatus />
</form>
)
}
describe('useFormStatus must be called from a child of <form>', () => {
it('same-component placement never reports pending: true', async () => {
render(<FormWrong />)
fireEvent.click(screen.getByText('Save (wrong)'))
// Give the pending Transition a moment to commit if it were going to.
await new Promise((r) => setTimeout(r, 20))
expect(screen.queryByText('Saving (wrong)...')).toBeNull()
expect(screen.getByText('Save (wrong)')).toBeTruthy()
})
it('child-of-form placement reports pending: true while the action is in flight', async () => {
render(<FormRight />)
fireEvent.click(screen.getByText('Save (right)'))
await waitFor(() => expect(screen.getByText('Saving (right)...')).toBeTruthy())
await waitFor(() => expect(screen.getByText('Save (right)')).toBeTruthy())
})
})

ทั้งสอง test ผ่าน: ข้อความบนปุ่มของ FormWrong ไม่เคยเปลี่ยนเป็น "Saving (wrong)..." เลยตลอด submission ในขณะที่ FormRight เปลี่ยน — เป็นหลักฐาน ไม่ใช่แค่เอกสาร ว่าการวางตำแหน่งแบบ component เดียวกันทำให้ไม่เคยรายงาน pending: true เลย

เพื่อให้ UX กระฉับกระเฉง คุณมักอยากแสดงผลลัพธ์ที่ คาดว่าจะเกิด ทันทีและแก้ก็ต่อเมื่อ request ล้มเหลว useOptimistic ให้ optimistic value ที่คุณ set ทันทีและ React จะ revert กลับไปเป็นค่าจริงอัตโนมัติเมื่อ action จบ:

import { useOptimistic } from 'react';
function Thread({ messages, sendMessage }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current, newText) => [...current, { text: newText, sending: true }],
);
async function formAction(formData) {
const text = formData.get('message');
addOptimistic(text); // show it immediately, marked "sending"
await sendMessage(text); // real request; React reconciles when it resolves
}
return (
<>
{optimisticMessages.map((m, i) => (
<p key={i}>{m.text} {m.sending && <small>(sending…)</small>}</p>
))}
<form action={formAction}>
<input name="message" />
<button type="submit">Send</button>
</form>
</>
);
}

message ปรากฏทันทีที่คุณ submit เมื่อ sendMessage resolve, React จะ drop entry ที่เป็น optimistic แล้วใช้ state จริงแทน; ถ้า sendMessage ล้มเหลว React จะ revert อัตโนมัติ คุณจึงไม่ต้อง roll back เอง

sequenceDiagram
  participant U as User
  participant O as useOptimistic
  participant S as server action
  U->>O: submit (addOptimistic)
  O->>U: show optimistic value instantly
  O->>S: await real request
  S-->>O: success then keep real state
  Note over O,U: on failure React reverts automatically
optimistic update แล้วค่อย settle

สอง hook นี้รวมกันทำให้ form รู้สึกทันใจ: useOptimistic สำหรับ feedback ทันที, useFormStatus สำหรับ affordance เรื่อง pending บนปุ่ม — ทั้งคู่โดยไม่ต้องร้อย state ผ่าน props

useOptimistic นอก Transition หรือ Action: warning จริง ไม่ใช่แค่เรื่อง style

หัวข้อที่มีชื่อว่า “useOptimistic นอก Transition หรือ Action: warning จริง ไม่ใช่แค่เรื่อง style”

setter ที่ useOptimistic ให้มา (addOptimistic ข้างบน) เป็น API ที่ใช้ได้เฉพาะใน Action เหมือน requestFormReset เรียกมันตรง ๆ จาก event handler ธรรมดา — ไม่ใช่ใน startTransition และไม่ใช่ใน form Action — react.dev บอกไว้ตรง ๆ ว่าจะเกิดอะไรขึ้น:

The optimistic setter function must be called inside startTransition or within an Action. Calling the setter outside an Action will cause the optimistic state to revert immediately because there is no Transition holding it. — react.dev/reference/react/useOptimistic

พิสูจน์แล้วตรง ๆ รวมถึงข้อความ console ที่ตรงตัวอักษร (จับได้จากการรันจริง ไม่ใช่ paraphrase จากเอกสาร — สังเกตว่ามันเป็นตัวพิมพ์เล็ก “transition or action” ไม่เหมือน prose บางส่วนของ react.dev เอง):

src/__tests__/use-optimistic-outside-action.test.tsx
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { useOptimistic, useState } from 'react'
function BadOptimistic() {
const [liked] = useState(false)
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
// ❌ called directly in an event handler — not inside startTransition or an Action.
setOptimisticLiked(true)
}
return <button onClick={handleClick}>{optimisticLiked ? 'liked' : 'like'}</button>
}
describe('useOptimistic called outside a Transition/Action', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('logs the "outside a Transition or Action" console.error and the optimistic value reverts immediately', async () => {
const errors: string[] = []
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
errors.push(args.map(String).join(' '))
})
render(<BadOptimistic />)
fireEvent.click(screen.getByText('like'))
await waitFor(() => {
expect(errors.length).toBeGreaterThan(0)
})
expect(errors.some((e) => e.includes('outside a transition or action'))).toBe(true)
// The optimistic value briefly renders, then reverts because no Transition holds it.
expect(screen.getByText('like')).toBeTruthy()
})
})

ข้อความบนปุ่มจบลงที่ 'like' — พฤติกรรม “revert ทันที” ตามที่เอกสารบอกเป๊ะ ๆ — และ console มีข้อความ warning จริง นี่คือ bug ตระกูลเดียวกับการเรียก requestFormReset นอก Action (บทก่อนหน้า): ทั้งคู่ compile ได้, “ทำงานได้แบบหนึ่ง” และทั้งคู่แอบบอกคุณเงียบ ๆ ว่าถูกใช้ผิด

flowchart LR
  a["setOptimistic(x) outside startTransition/Action"] --> b["console.error:
outside a transition or action"]
  b --> c["value reverts immediately
(no Transition holding it)"]
  d["setOptimistic(x) inside startTransition/Action"] --> e["no warning
value holds until settle"]
setter สอง API ที่ใช้ได้เฉพาะใน Action มี warning shape เดียวกัน

useFormStatus กับ useOptimistic ทั้งคู่คือ การอ่าน Transition ที่กำลัง active อยู่ ไม่ใช่ state อิสระที่คุณจัดการเอง useFormStatus อ่านว่า Transition ของ form ที่เป็น ancestor ยังรันอยู่ไหม useOptimistic setter ต้องการ Transition ที่ active เพื่อมีที่ “เก็บ” ค่าไว้จนกว่า Transition นั้นจะ settle ไม่มี hook ไหน error ดัง ๆ เมื่อใช้ผิด — ทั้งคู่ลดระดับเป็น “เงียบ ๆ ทำสิ่งที่มีประโยชน์น้อยกว่า” ซึ่งเป็นเหตุผลว่าทำไมการพิสูจน์พฤติกรรมจริง (ไม่ใช่แค่อ่านเอกสาร) ถึงสำคัญตรงนี้

Hookได้อะไรต้นทุน
useFormStatusไม่ต้อง prop drill pending stateทำงานเฉพาะจาก descendant ของ <form> — ไม่ใช่ component ที่ render มัน พิสูจน์แล้วข้างบน
useOptimisticfeedback ทันทีก่อน server ตอบsetter ต้องรันใน Transition/Action ไม่งั้น warning แล้ว revert ทันที
  • “useFormStatus อ่าน form ที่ใกล้ที่สุดในไฟล์” มันอ่าน form ที่เป็น ancestor ใกล้ที่สุดใน render tree component ที่ render <form> เองไม่ใช่ descendant ของ form นั้น — พิสูจน์แล้วข้างบนด้วย component สองตัวที่หน้าตาเกือบเหมือนกัน
  • “setter ของ useOptimistic เรียกจากที่ไหนก็ได้ เหมือน state setter ธรรมดา” มันต้องการ Transition หรือ Action ที่ active อยู่โดยเฉพาะ เรียกจาก event handler ธรรมดาแล้ว มัน warning และ optimistic value revert ก่อนที่ user จะทันเห็นมัน settle ด้วยซ้ำ
  • “ถ้าไม่มีอะไร throw แปลว่าโค้ดถูก” bug ทั้งสองในหน้านี้ — useFormStatus ผิด component, useOptimistic นอก Transition — compile ผ่าน รันได้ ไม่ crash อาการเดียวที่มีคือ flag pending ที่เป็น false ตลอด หรือ console warning บวกกับค่าที่ revert ทันที
  • useFormStatus ถูกเรียกใน component ที่เป็น child ของ <form> ไหม หรืออยู่ใน component เดียวกับที่ render tag <form>
  • setter ของ useOptimistic (addOptimistic / setOptimistic / ชื่ออะไรก็ตาม) ถูกเรียกจากใน startTransition หรือ form Action ไหม — หรือเรียกตรง ๆ จาก event handler
  • ถ้าใช้ useOptimistic โค้ดรอบ ๆ มี try/catch ครอบการเรียก action ไหม เพื่อให้ rejection path รันจริงและ error state ของ UI ถูก set
  • confirmed state อัปเดตเฉพาะใน success path ไหม เพื่อไม่ให้ action ที่ล้มเหลว “ยืนยัน” ข้อมูลที่ไม่เคยถูกเขียนจริง

🐛 หา bug ในโค้ดนี้

function LikeButton({ liked }: { liked: boolean }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
function handleClick() {
setOptimisticLiked(!optimisticLiked)
saveLike(!optimisticLiked)
}
return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
}
เฉลย

setOptimisticLiked ถูกเรียกตรง ๆ ใน onClick handler ธรรมดา — ไม่ใช่ใน startTransition และไม่ใช่ใน form Action ตาม react.dev “Calling the setter outside an Action will cause the optimistic state to revert immediately because there is no Transition holding it” พิสูจน์แล้วข้างบน: ปุ่มโชว์ label แบบ optimistic แว้บหนึ่ง แล้ว revert ในขณะที่ console log ข้อความ warning จริง ("An optimistic state update occurred outside a transition or action...") — “feedback ทันที” ที่โค้ดนี้พยายามทำจึงไม่เคยอยู่จริง วิธีแก้คือห่อการเรียก: startTransition(() => { setOptimisticLiked(!optimisticLiked); saveLike(!optimisticLiked); })

useFormStatus ให้อะไรกับ component?
component ที่เรียก useFormStatus ต้องถูก render ที่ไหน?
useOptimistic ทำอะไรเมื่อ async action ล้มเหลว?
ระหว่างที่ slowSave กำลังทำงาน pending ตรงนี้แสดงค่าอะไร?
function FormWrong() {
  const { pending } = useFormStatus()
  return (
    <form action={slowSave}>
      <button disabled={pending}>Save</button>
    </form>
  )
}
เกิดอะไรขึ้นถ้า setOptimistic(newValue) (setter จาก useOptimistic) ถูกเรียกตรง ๆ จาก onClick handler ธรรมดา ไม่ใช่ใน startTransition หรือ Action?
วิธีแก้ที่ถูกต้องที่สุดสำหรับ component นี้คืออะไร?
function LikeButton({ liked }: { liked: boolean }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked)
  function handleClick() {
    setOptimisticLiked(!optimisticLiked)
    saveLike(!optimisticLiked)
  }
  return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>
}