Forms และ Progressive Enhancement
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”การต่อ Server Action เข้ากับ <form action={action}> ทำให้เราได้ form ที่ submit และ mutate ได้ตั้งแต่ก่อน JavaScript โหลด แล้ว upgrade เป็นประสบการณ์ฝั่ง client ที่สมบูรณ์ขึ้นเมื่อ React hydrate เสร็จ
Progressive enhancement for free
หัวข้อที่มีชื่อว่า “Progressive enhancement for free”เพราะ action ผูกกับ <form> จริง browser จึง post form ด้วย HTTP request ธรรมดาได้ mutation ทำงานได้แม้ปิด JavaScript หรือยังโหลดไม่เสร็จ เมื่อ React hydrate หน้าเสร็จ form เดิมจะถูก intercept ฝั่ง client จึงไม่มีการ reload ทั้งหน้า เราได้ baseline ฟรี และได้ upgrade อัตโนมัติ
// app/new-todo.tsx — works before JS, upgrades after hydrationimport { createTodo } from './actions'
export function NewTodoForm() { return ( <form action={createTodo}> <input name="title" required /> <button type="submit">Add todo</button> </form> )}useActionState for result, pending, and errors
หัวข้อที่มีชื่อว่า “useActionState for result, pending, and errors”useActionState(action, initialState) ห่อ action ไว้ เราจึงได้ state ล่าสุดที่ return กลับมา ได้ form action ไว้ bind และได้ flag pending action ของเรารับ state ก่อนหน้าเป็น argument แรก และ return state ใหม่ เหมาะมากกับการโชว์ validation error ข้าง field
// app/actions.ts — return field errors instead of throwing'use server'
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 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 } }) return { errors: {} }}// app/new-todo.tsx — bind the action with useActionState'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 for the submit button
หัวข้อที่มีชื่อว่า “useFormStatus for the submit button”useFormStatus อ่าน pending state ของ form ที่ครอบอยู่ วางไว้ใน child component ของ form เพื่อให้ปุ่ม disable ตัวเองระหว่าง action รันได้ โดยไม่ต้องส่ง props ลงไป
// app/submit-button.tsx — a child of the form'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() { const { pending } = useFormStatus() return ( <button disabled={pending} type="submit"> {pending ? 'Saving…' : 'Add todo'} </button> )}useOptimistic for instant feedback
หัวข้อที่มีชื่อว่า “useOptimistic for instant feedback”useOptimistic ให้เราโชว์ผลลัพธ์ที่คาดไว้ทันที ก่อน server ตอบกลับ เรา render list แบบ optimistic เพิ่ม item ใหม่ตอน submit แล้ว React จะ reconcile เมื่อข้อมูลจริงมาถึง
// app/thread.tsx — optimistic update before the action resolves'use client'
import { useOptimistic } from 'react'import { send } from './actions'
type Message = { message: string }
export function Thread({ messages }: { messages: Message[] }) { const [optimistic, addOptimistic] = useOptimistic<Message[], string>( messages, (state, next) => [...state, { message: next }], )
async function action(formData: FormData) { const message = String(formData.get('message')) addOptimistic(message) await send(message) }
return ( <form action={action}> {optimistic.map((m, i) => <p key={i}>{m.message}</p>)} <input name="message" /> <button type="submit">Send</button> </form> )}
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"]