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

Forms และ Progressive Enhancement

การต่อ Server Action เข้ากับ <form action={action}> ทำให้เราได้ form ที่ submit และ mutate ได้ตั้งแต่ก่อน JavaScript โหลด แล้ว upgrade เป็นประสบการณ์ฝั่ง client ที่สมบูรณ์ขึ้นเมื่อ React hydrate เสร็จ

เพราะ 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 hydration
import { createTodo } from './actions'
export function NewTodoForm() {
return (
<form action={createTodo}>
<input name="title" required />
<button type="submit">Add todo</button>
</form>
)
}

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 อ่าน 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 ให้เราโชว์ผลลัพธ์ที่คาดไว้ทันที ก่อน 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"]
Baseline submit then hydrated upgrade
Why does a form action work before JavaScript loads?
What does useActionState give you back?
Where must useFormStatus be called to read the form pending state?
What is the purpose of useOptimistic?