Mutations และ Revalidation
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”มุตเมชันคือ flow ห้าขั้น: authorize ผู้เรียก, validate input, เขียนลง database, เรียก updateTag, revalidateTag, หรือ revalidatePath เพื่อ refresh ข้อมูลที่ cache ไว้ที่กระทบ แล้ว redirect ถ้าต้องการ — เพื่อให้ UI สะท้อนการเปลี่ยนแปลงโดยไม่ต้อง reload เอง
Flow เต็มของมุตเมชัน
หัวข้อที่มีชื่อว่า “Flow เต็มของมุตเมชัน”ใน action เรา authorize ก่อน validate ต่อ แล้วค่อย mutate แล้วค่อย revalidate ข้อมูลที่เพิ่งเปลี่ยน หลังจาก revalidate แล้วเท่านั้นถึง redirect เพราะ redirect throw ข้างในและหยุด function ส่วนที่เหลือไม่ให้รัน
export const db = { post: { create: async (_args: { data: { title: string; authorId?: string } }) => {}, update: async (_args: { where: { id: string; authorId?: string }; data: { published?: boolean } }) => {}, },}'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { revalidatePath } from 'next/cache'import { redirect } from 'next/navigation'import { z } from 'zod'
const schema = z.object({ title: z.string().min(1) })
export async function createPost(formData: FormData) { const { userId } = await verifySession() const parsed = schema.safeParse({ title: formData.get('title') }) if (!parsed.success) { return { error: 'Invalid title' } }
await db.post.create({ data: { title: parsed.data.title, authorId: userId } })
revalidatePath('/posts') redirect('/posts')}redirect() กับ notFound() throw — try/catch กลืนมันได้
หัวข้อที่มีชื่อว่า “redirect() กับ notFound() throw — try/catch กลืนมันได้”redirect() และ notFound() ทั้งคู่ทำงานด้วยการ throw control-flow error พิเศษที่ Next.js ดักจับไว้ชั้นบนแล้วแปลงเป็น navigation หรือ segment not-found ที่ render ถ้าเราครอบ call ที่สร้าง error นั้นด้วย try/catch ของเราเอง catch block ของเราจะดักมันไปก่อน Next.js ไม่มีวันเห็น แล้ว redirect หรือ 404 ก็ไม่เกิดขึ้นเลยเงียบ ๆ
import { notFound, unstable_rethrow } from 'next/navigation'
// BUG: try/catch swallows notFound()export async function getPostBad(id: string) { try { if (id === 'missing') notFound() return { id, ok: true } } catch (err) { console.error('swallowed:', err) return { id, ok: false, error: 'caught, not a real 404' } }}
// FIX: unstable_rethrow lets Next.js's own errors escapeexport async function getPostGood(id: string) { try { if (id === 'missing') notFound() return { id, ok: true } } catch (err) { unstable_rethrow(err) console.error('real application error:', err) return { id, ok: false, error: 'other error' } }}Build ทั้งสองแบบกับ Next.js 16.3.6 แล้ว request จาก server จริง เห็นความต่างตรง ๆ ใน HTTP response เลย ไม่ใช่แค่ทฤษฎี
bad variant -> HTTP 200good variant -> HTTP 404variant “bad” รัน catch block, log error แล้ว return object ปกติ — หน้า render ด้วย ok: false และ status 200 variant “good” rethrow ด้วย unstable_rethrow สัญญาณ NEXT_HTTP_ERROR_FALLBACK;404 ของ Next.js เอง เลยหลุดออกจาก catch block ไปถึง framework ซึ่ง render หน้า not-found จริงและ return 404 digest ที่ฝังอยู่ใน RSC payload ของ response นั้น — {"digest":"NEXT_HTTP_ERROR_FALLBACK;404"} — คือ control-flow marker ของ Next.js เอง กลไกเดียวกับที่ redirect() ใช้สำหรับ navigation
unstable_rethrow ยังเป็นชื่อ API แบบ unstable/experimental ณ Next.js 16.3.6 — คำว่า “unstable” สะท้อนสถานะปัจจุบันของมัน ไม่ใช่พิมพ์ผิด Next.js แนะนำให้ใช้เฉพาะกับ try/catch ที่ปนกันระหว่าง application error กับ error ที่ framework ควบคุม (redirect(), permanentRedirect(), notFound(), และ request-time API อย่าง cookies()/headers() ภายใต้ rendering mode บางแบบ) ถ้าจัดโครงสร้างโค้ดให้ redirect()/notFound() ถูกเรียก นอก try/catch ได้เลย — เหมือนตัวอย่าง createPost ด้านบน — แบบนั้นง่ายกว่าและเลี่ยงปัญหาทั้งหมดได้ ใช้ unstable_rethrow เฉพาะตอนที่ call ของ framework หนีไม่พ้นต้องอยู่ใน block ที่เราต้อง catch error อื่นด้วยเท่านั้น
updateTag, revalidateTag, และ revalidatePath
หัวข้อที่มีชื่อว่า “updateTag, revalidateTag, และ revalidatePath”revalidatePath('/posts') invalidate ทุกอย่างที่ cache ไว้สำหรับ route นั้น การ invalidate แบบ tag แม่นยำกว่าเมื่อข้อมูลเดียวกันปรากฏในหลาย route: tag การอ่านไว้ก่อน แล้วค่อย expire tag หลัง write ภายใน Server Action ใช้ updateTag('posts') — มัน expire tag ทันที request ถัดไปจึงรอข้อมูลใหม่ ผู้ใช้เลยเห็น write ของตัวเองทันที revalidateTag(tag, profile) ต่างออกไป มัน mark tag ว่า stale แล้วยังเสิร์ฟข้อมูลเก่าต่อไปพลาง refresh เบื้องหลัง ใน Next.js 16 argument profile ตัวที่สองเป็น required (แบบ argument เดียว deprecated และเป็น TypeScript error) และ 'max' คือค่าที่แนะนำ ใช้ revalidateTag ใน Route Handler และ webhook ที่ updateTag ใช้ไม่ได้
export async function getPosts() { const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, }) return res.json()}'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { updateTag } from 'next/cache'
export async function publishPost(id: string) { const { userId } = await verifySession() await db.post.update({ where: { id, authorId: userId }, data: { published: true } }) updateTag('posts')}import { revalidateTag } from 'next/cache'
export async function POST() { revalidateTag('posts', 'max') return Response.json({ revalidated: true })}client cache ถูก refresh อย่างไร
หัวข้อที่มีชื่อว่า “client cache ถูก refresh อย่างไร”ส่วนนี้ต่อกับโมดูล caching เมื่อ action เรียก updateTag, revalidatePath, หรือ redirect Next.js จะ revalidate และ re-render route ปัจจุบันภายใน HTTP round trip เดียวกัน — response ของ action พ่วงทั้งค่าที่ return และ RSC payload ที่ render ใหม่มาด้วย app เราเลยไม่ต้อง fetch ซ้ำเพื่อเห็น UI ที่อัปเดตแล้ว revalidateTag แบบ stale-while-revalidate profile เป็นข้อยกเว้นเดียว มันไม่บังคับ re-render ทันทีแบบนั้น หน้าปัจจุบันจึงยังโชว์ค่าเก่าต่อไปพลาง tag refresh อยู่เบื้องหลัง แล้วการเปลี่ยนแปลงจะเห็นได้ตอน navigate หรืออ่านครั้งถัดไปแทน
graph TD A["verifySession() then schema.safeParse()"] --> B["Writes to the database"] B --> C["updateTag, revalidateTag, or revalidatePath"] C --> D["Data Cache entry cleared"] C --> E["Client Cache refreshed"] D --> F["UI shows fresh data"] E --> F B --> G["redirect() outside any try/catch"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มองว่า redirect() กับ notFound() เหมือน exception ที่เราตั้งใจปล่อยให้ลอยขึ้นไป: หน้าที่ทั้งหมดของมันคือ unwind stack แล้วส่งการควบคุมให้ Next.js try/catch ที่ครอบโค้ดซึ่งอาจเรียกมันคือ catch clause สำหรับ error ของเรา ไม่ใช่ของมัน — ถ้าไม่ตั้งใจปล่อยให้ error ของ framework หลุดผ่านไป catch block ของเราจะกลายเป็นที่สุดท้ายที่มันถูกเห็น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| กลไก | re-render ใน round trip เดียวกันไหม | ใช้เมื่อ |
|---|---|---|
updateTag(tag) | ใช่ — read ถัดไปรอข้อมูลใหม่ | ผู้ใช้ต้องเห็น write ของตัวเองทันที |
revalidateTag(tag, profile) | ไม่ — stale-while-revalidate เบื้องหลัง | Route Handler/webhook หรือตอนไม่ต้อง re-render ทันที |
revalidatePath(path) | ใช่ | route เดียวกระทบ tagging เกินความจำเป็น |
redirect() / notFound() นอก try/catch | ไม่เกี่ยว — throw แล้วส่งให้ Next.js จัดการ | ทุกครั้ง ที่ต้องการให้ framework จัดการ navigation หรือ 404 |
redirect() / notFound() ใน try/catch ที่ไม่ได้ป้องกัน | ไม่เกี่ยว — ถูกกลืนเงียบ ๆ | ไม่ควรเกิดขึ้นเลย — นี่คือ bug ที่บทนี้พิสูจน์ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ครอบ body ของ action ทั้งก้อนด้วย try/catch ปลอดภัยกว่าเสมอ” ปลอดภัยกว่าสำหรับ error ของเรา แต่มันปิด
redirect()กับnotFound()เงียบ ๆ ถ้าถูกเรียกอยู่ข้างในนั้น — พิสูจน์แล้วด้านบน: โค้ด path เดียวกัน return HTTP 200 แทนที่จะเป็น 404 - “
revalidateTagกับupdateTagใช้แทนกันได้”updateTagบังคับให้ response ปัจจุบันรอข้อมูลใหม่ ส่วนrevalidateTagตั้งใจไม่ทำแบบนั้น ใช้ผิดตัวจะเพิ่ม latency โดยไม่จำเป็น หรือเสิร์ฟข้อมูลเก่าตอนที่ผู้ใช้คาดว่าจะเห็น write ของตัวเอง - “
redirect()return ค่า เลยควรreturn redirect(...)” มันไม่ return เลย TypeScript type มันเป็นneverเพราะมัน throw เสมอ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างทำ mutation และ revalidation ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ
- body ของ action ทั้งก้อนถูกครอบด้วย
try/catchเดียว โดยมีredirect()หรือnotFound()ถูกเรียกอยู่ข้างในไหม แบบนั้นจะกลืน control-flow error ของ framework เอง revalidateTagถูกเรียกด้วย argument เดียวไหม ใน Next.js 16 argumentprofileเป็น required — เรียกแบบ argument เดียวเป็น type error ไม่ใช่แค่ lint warningupdateTagถูกใช้ตรงที่โค้ดต้องการ background revalidation จริง ๆ ไหม (หรือกลับกัน) อ่าน intent ให้ดี: ผู้ใช้ต้องเห็น write ของตัวเองทันทีไหมredirect()มาหลัง revalidation call ไหม เพื่อให้ route ปลายทางสะท้อนข้อมูลใหม่แล้ว
🐛 หา bug ในโค้ดนี้
import { notFound } from 'next/navigation'
export async function getPost(id: string) {try { const post = await db.post.findUnique({ where: { id } }) if (!post) notFound() return post} catch (err) { console.error('Failed to load post', err) return null}}เฉลย
notFound() throw internal error NEXT_HTTP_ERROR_FALLBACK ของ Next.js เพื่อให้ framework render segment not-found ที่นี่มันถูกเรียกอยู่ใน try block ที่ catch ของมัน log error อะไรก็ตาม แล้ว return null แทน — รวมถึง error ที่ notFound() เพิ่ง throw ด้วย พิสูจน์แล้วกับ server จริง: logic แบบเดียวกัน build และ deploy แล้วตอบกลับด้วย HTTP 200 พร้อม JSON body แทนที่จะเป็นหน้า 404 วิธีแก้คือเรียก notFound() นอก try/catch ไปเลย หรือเรียก unstable_rethrow(err) เป็นบรรทัดแรกของ catch block เพื่อให้ error ของ Next.js เองหลุดผ่านต่อไปได้