Forms และ Progressive Enhancement
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”การต่อ Server Action เข้ากับ <form action={action}> ทำให้เราได้ form ที่ submit และ mutate ได้ตั้งแต่ก่อน JavaScript โหลด แล้ว upgrade เป็นประสบการณ์ฝั่ง client ที่สมบูรณ์ขึ้นเมื่อ React hydrate เสร็จ
ได้ progressive enhancement ฟรี
หัวข้อที่มีชื่อว่า “ได้ progressive enhancement ฟรี”เพราะ action ผูกกับ <form> จริง browser จึง post form ด้วย HTTP request ธรรมดาได้ mutation ทำงานได้แม้ปิด JavaScript หรือยังโหลดไม่เสร็จ เมื่อ React hydrate หน้าเสร็จ form เดิมจะถูก intercept ฝั่ง client จึงไม่มีการ reload ทั้งหน้า เราได้ baseline ฟรี และได้ upgrade อัตโนมัติ
import { createTodo } from './actions'
export function NewTodoForm() { return ( <form action={createTodo}> <input name="title" required /> <button type="submit">Add todo</button> </form> )}useActionState ต้องการ (prevState, formData) — ไม่ใช่ (formData)
หัวข้อที่มีชื่อว่า “useActionState ต้องการ (prevState, formData) — ไม่ใช่ (formData)”Server Action ธรรมดารับ formData เป็น argument เดียว เหมือนหัวข้อด้านบน แต่ useActionState(action, initialState) ต้องการรูปแบบที่ต่างออกไป: action ที่มี argument แรก เป็น state ก่อนหน้า และ argument ที่สอง เป็น formData การเอา action ธรรมดามาสลับใส่ useActionState ไม่ใช่แค่เรื่อง style ที่ปล่อยผ่านได้ — มันเป็น TypeScript error จริง เพราะสอง shape นี้เข้ากันไม่ได้
'use server'
export async function createTodoPlain(formData: FormData) { const title = String(formData.get('title')) return { ok: true, title }}// @expect-error swapping a plain (formData) action into useActionState is a type error'use client'
import { useActionState } from 'react'import { createTodoPlain } from './actions'
export function BadForm() { const [state, formAction] = useActionState(createTodoPlain, { ok: false, title: '' }) return ( <form action={formAction}> <input name="title" /> <button type="submit">Add</button> </form> )}Compile กับ Next.js 16.3.6 / React 19.2.8 ได้ผลลัพธ์นี้
error TS2769: No overload matches this call. Overload 1 of 2, '(action: (state: { ok: boolean; title: string; }) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>, initialState: { ok: boolean; title: string; }, permalink?: string | undefined): [state: ...]', gave the following error. Argument of type '(formData: FormData) => Promise<{ ok: boolean; title: string; }>' is not assignable to parameter of type '(state: { ok: boolean; title: string; }) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>'. Types of parameters 'formData' and 'state' are incompatible. Type '{ ok: boolean; title: string; }' is missing the following properties from type 'FormData': append, delete, get, getAll, and 7 more. Overload 2 of 2, '(action: (state: { ok: boolean; title: string; }, payload: unknown) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>, initialState: { ok: boolean; title: string; }, permalink?: string | undefined): [state: ...]', gave the following error. Argument of type '(formData: FormData) => Promise<{ ok: boolean; title: string; }>' is not assignable to parameter of type '(state: { ok: boolean; title: string; }, payload: unknown) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>'. Types of parameters 'formData' and 'state' are incompatible. Type '{ ok: boolean; title: string; }' is missing the following properties from type 'FormData': append, delete, get, getAll, and 7 more.TypeScript กำลังบอกอ้อม ๆ ว่า useActionState พยายามมองว่า state ที่ action return กลับมา คือ argument formData ตัวถัดไป วิธีแก้คือเติม parameter prevState ให้ action ส่วน authorize กับ validate ยังอยู่ตำแหน่งเดิม
useActionState สำหรับผลลัพธ์, pending และ error
หัวข้อที่มีชื่อว่า “useActionState สำหรับผลลัพธ์, pending และ error”useActionState(action, initialState) ห่อ action ไว้ เราจึงได้ state ล่าสุดที่ return กลับมา ได้ form action ไว้ bind และได้ flag pending action ของเรารับ state ก่อนหน้าเป็น argument แรก และ return state ใหม่ เหมาะมากกับการโชว์ validation error ข้าง field เหมือนทุก action ในบทนี้ ต้อง authorize ด้วย verifySession() ก่อน validate input
export const db = { todo: { create: async (_args: { data: { title: string; userId?: string } }) => {}, },}'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { z } from 'zod'
const schema = z.object({ title: z.string().min(1, 'Title is required') })
export async function createTodo(prevState: unknown, formData: FormData) { const { userId } = await verifySession() const parsed = schema.safeParse({ title: formData.get('title') }) if (!parsed.success) { return { errors: parsed.error.flatten().fieldErrors } } await db.todo.create({ data: { title: parsed.data.title, userId } }) return { errors: {} }}'use client'
import { useActionState } from 'react'import { createTodo } from './actions'
export function NewTodoForm() { const [state, formAction, pending] = useActionState(createTodo, { errors: {} })
return ( <form action={formAction}> <input name="title" /> {state.errors?.title && <p>{state.errors.title[0]}</p>} <button disabled={pending} type="submit">Add todo</button> </form> )}useFormStatus ต้องเรียกจาก child ของ form
หัวข้อที่มีชื่อว่า “useFormStatus ต้องเรียกจาก child ของ form”useFormStatus อ่าน pending state ของ form ที่ ครอบอยู่ — ครอบในที่นี้แปลว่าเป็น ancestor จริงใน render tree ไม่ใช่ “component ที่บังเอิญ render tag form” การเรียกมันใน component เดียวกับที่ render <form> เป็นความผิดพลาดที่พบบ่อย: มัน compile ผ่าน รันได้ แต่ไม่เคยรายงาน pending: true เลยเงียบ ๆ เพราะ component นั้นไม่ใช่ descendant ของ form ตัวเอง
'use client'
import { useFormStatus } from 'react-dom'
export function FormWrong({ action }: { action: (formData: FormData) => void }) { const { pending } = useFormStatus() return ( <form action={action}> <input name="title" /> <button disabled={pending} type="submit"> {pending ? 'Saving...' : 'Save (wrong)'} </button> </form> )}'use client'
import { useFormStatus } from 'react-dom'
function SubmitStatus() { const { pending } = useFormStatus() return ( <button disabled={pending} type="submit"> {pending ? 'Saving...' : 'Save (right)'} </button> )}
export function FormRight({ action }: { action: (formData: FormData) => void }) { return ( <form action={action}> <input name="title" /> <SubmitStatus /> </form> )}ทั้งสองแบบ compile ผ่านและรันได้ ความต่างจะโผล่มาเฉพาะตอน action กำลังรันอยู่เท่านั้น ดังนั้นโค้ดที่ AI สร้างจึงดูถูกต้องได้ทั้งที่ผิด รันทั้งสองแบบกับ slowSave action ที่หน่วงไว้ 15 วินาทีเดียวกัน ใน browser จริง (Next.js 16.3.6, React 19.2.8) แล้วอ่านสถานะของปุ่มแต่ละอันหลังกด submit 200ms
FormRight -> "pending: true" (SubmitStatus is a child of <form>)FormWrong -> "pending: false" (useFormStatus is in the same component as <form>, never true)useOptimistic สำหรับ feedback ทันที พร้อม rollback เมื่อล้มเหลว
หัวข้อที่มีชื่อว่า “useOptimistic สำหรับ feedback ทันที พร้อม rollback เมื่อล้มเหลว”useOptimistic ให้เราโชว์ผลลัพธ์ที่คาดไว้ทันที ก่อน server ตอบกลับ เรา render list แบบ optimistic เพิ่ม item ใหม่ตอน submit แล้ว React จะ reconcile เมื่อข้อมูลจริงมาถึง — และถ้า action throw แทนที่จะ resolve React จะทิ้ง optimistic update ที่ค้างอยู่ แล้ว UI จะกลับไปเป็น state ล่าสุดที่ยืนยันแล้ว
'use server'
export async function send(message: string) { await new Promise((r) => setTimeout(r, 300)) if (message === 'boom') { throw new Error('send failed') }}'use client'
import { useOptimistic, useState } from 'react'import { send } from './thread-actions'
type Message = { message: string }
export function Thread() { const [messages, setMessages] = useState<Message[]>([{ message: 'hello' }]) const [optimistic, addOptimistic] = useOptimistic<Message[], string>( messages, (state, next) => [...state, { message: next }], ) const [status, setStatus] = useState('idle')
async function action(formData: FormData) { const message = String(formData.get('message')) addOptimistic(message) setStatus('sending') try { await send(message) setMessages((m) => [...m, { message }]) setStatus('idle') } catch (e) { setStatus('error: ' + (e as Error).message) } }
return ( <form action={action}> {optimistic.map((m, i) => <p key={i}>{m.message}</p>)} <input name="message" /> <button type="submit">Send</button> </form> )}Submit ข้อความ "boom" กับ action ที่ reject ค่านี้เจาะจง (Next.js 16.3.6, React 19.2.8, ใน browser จริง) ได้ลำดับเหตุการณ์นี้
before submit -> list: "hello"100ms later -> list: "hello, boom" (optimistic entry showing already)600ms later -> list: "hello" (action rejected — optimistic entry rolled back) status: "error: send failed"ข้อความ optimistic ไม่เคยกลายเป็นส่วนหนึ่งของ messages state ที่ยืนยันแล้วเลย เพราะ setMessages รันเฉพาะใน branch try เท่านั้น — useOptimistic เองเป็นคนทิ้ง pending update ทันทีที่ promise ของ action ถูก reject
graph TD
A["form action = Server Action"] --> B{"Has JS hydrated?"}
B -->|"No"| C["Plain HTTP POST, full navigation"]
B -->|"Yes"| D["Client intercepts, no reload"]
D --> E["useActionState + useFormStatus + useOptimistic"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”useActionState, useFormStatus, useOptimistic คือมุมมองอิสระสามมุมของ request เดียวกันที่กำลังทำงานอยู่ ไม่ใช่สามส่วนของ API เดียว useActionState อยู่ที่ component ที่เป็นเจ้าของ action และผลลัพธ์ของมัน useFormStatus อยู่ที่ descendant ของ <form> และรายงานสถานะของ form นั้นเท่านั้น useOptimistic อยู่ตรงไหนก็ได้ที่เรา render list ที่อยากให้รู้สึกทันที และมันคลาย pending update ให้เองอัตโนมัติทันทีที่ promise ของ action ถูก reject
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Hook | ได้อะไร | ต้นทุน |
|---|---|---|
useActionState | return value, form action ที่ bind แล้ว, pending flag ในตัวเดียว | action signature ต้องเป็น (prevState, formData) — ไม่งั้น type error จริง |
useFormStatus | ไม่ต้อง prop drill pending state | ทำงานเฉพาะจาก descendant ของ <form> — ไม่ใช่ component ที่ render มัน |
useOptimistic | feedback ทันทีก่อน server ตอบ | ต้อง handle rejection path เอง ไม่งั้น optimistic entry ค้างอยู่ตลอด |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “async function ไหนก็ใช้กับ
useActionStateได้” ใช้ได้เฉพาะตัวที่ argument แรกเป็น state ก่อนหน้าเท่านั้น action แบบ(formData) => ...ธรรมดา type-check ไม่ผ่าน - “
useFormStatusอ่าน form ที่ใกล้ที่สุดในไฟล์” มันอ่าน form ที่เป็น ancestor ใกล้ที่สุดใน render tree component ที่ render<form>เองไม่ใช่ descendant ของ form นั้น - “
useOptimisticต้องเรียก rollback เอง” ไม่ต้อง — rollback เกิดอัตโนมัติทันทีที่ promise ของ action ถูก reject สิ่งที่ต้องเขียนเองคือ error state ที่ UI จะโชว์หลังจากนั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างใช้ hook พวกนี้ ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
- action ที่ส่งให้
useActionStateรับ(prevState, formData)ไหม หรือยังเป็น action แบบเก่าที่รับแค่formData useFormStatusถูกเรียกใน component ที่เป็น child ของ<form>ไหม หรืออยู่ใน component เดียวกับที่ render tag<form>- ถ้าใช้
useOptimisticโค้ดรอบ ๆ มีtry/catch(หรือ.catch) ครอบการเรียก action ไหม เพื่อให้ rejection path รันจริง ถ้าไม่มี promise ที่ reject จะ unhandled แม้ optimistic UI จะยัง rollback ก็ตาม - confirmed state (
setMessagesในตัวอย่างนี้) อัปเดตเฉพาะใน success path ไหม เพื่อไม่ให้ action ที่ล้มเหลว “ยืนยัน” ข้อมูลที่ไม่เคยถูกเขียนจริง
🐛 หา bug ในโค้ดนี้
'use client'
import { useFormStatus } from 'react-dom'
export function SaveButton({ action }: { action: (formData: FormData) => void }) {const { pending } = useFormStatus()return ( <form action={action}> <input name="title" /> <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button> </form>)}เฉลย
useFormStatus ถูกเรียกใน SaveButton ซึ่งเป็น component เดียวกับที่ render element <form> — ไม่ใช่ component ที่ซ้อนอยู่ ข้างใน form ตามเอกสารของ React เอง useFormStatus “will only return status information for a parent <form>” และ component นี้ไม่ใช่ child ของ form ตัวเอง ปุ่มนี้ compile ผ่านและ render ได้ แต่ pending จะเป็น false ตลอดการ submit — พิสูจน์แล้วใน browser จริงกับ action ที่หน่วง 15 วินาที useFormStatus ตรงนี้อ่านได้ pending: false ตลอด ในขณะที่ hook แบบเดียวกันที่อยู่ลึกลงไปอีกหนึ่ง component อ่านได้ pending: true วิธีแก้คือแยกปุ่มออกมาเป็น component ของตัวเอง แล้ว render เป็น child ของ <form>