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

Mutations และ Revalidation

มุตเมชันคือ flow ห้าขั้น: authorize ผู้เรียก, validate input, เขียนลง database, เรียก updateTag, revalidateTag, หรือ revalidatePath เพื่อ refresh ข้อมูลที่ cache ไว้ที่กระทบ แล้ว redirect ถ้าต้องการ — เพื่อให้ UI สะท้อนการเปลี่ยนแปลงโดยไม่ต้อง reload เอง

ใน action เรา authorize ก่อน validate ต่อ แล้วค่อย mutate แล้วค่อย revalidate ข้อมูลที่เพิ่งเปลี่ยน หลังจาก revalidate แล้วเท่านั้นถึง redirect เพราะ redirect throw ข้างในและหยุด function ส่วนที่เหลือไม่ให้รัน

lib/db.ts
export const db = {
post: {
create: async (_args: { data: { title: string; authorId?: string } }) => {},
update: async (_args: { where: { id: string; authorId?: string }; data: { published?: boolean } }) => {},
},
}
app/actions.ts
'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 control-flow error พิเศษที่ Next.js ดักจับไว้ชั้นบนแล้วแปลงเป็น navigation หรือ segment not-found ที่ render ถ้าเราครอบ call ที่สร้าง error นั้นด้วย try/catch ของเราเอง catch block ของเราจะดักมันไปก่อน Next.js ไม่มีวันเห็น แล้ว redirect หรือ 404 ก็ไม่เกิดขึ้นเลยเงียบ ๆ

app/posts/actions.ts
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 escape
export 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 200
good variant -> HTTP 404

variant “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 อื่นด้วยเท่านั้น

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 ใช้ไม่ได้

app/posts/data.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
return res.json()
}
app/actions.ts
'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')
}
app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST() {
revalidateTag('posts', 'max')
return Response.json({ revalidated: true })
}

ส่วนนี้ต่อกับโมดูล 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"]
Flow ของ mutation และ revalidation

มองว่า 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 สร้างทำ mutation และ revalidation ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ

  • body ของ action ทั้งก้อนถูกครอบด้วย try/catch เดียว โดยมี redirect() หรือ notFound() ถูกเรียกอยู่ข้างในไหม แบบนั้นจะกลืน control-flow error ของ framework เอง
  • revalidateTag ถูกเรียกด้วย argument เดียวไหม ใน Next.js 16 argument profile เป็น required — เรียกแบบ argument เดียวเป็น type error ไม่ใช่แค่ lint warning
  • updateTag ถูกใช้ตรงที่โค้ดต้องการ 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 เองหลุดผ่านต่อไปได้

ลำดับที่ถูกต้องของ mutation flow คืออะไร
server จริงจะ return อะไรเมื่อ post ไม่มี
try {
  if (!post) notFound()
  return post
} catch (err) {
  console.error(err)
  return null
}
ทำไม redirect ต้องมาหลัง revalidation ใน action
unstable_rethrow ทำอะไรใน catch block
การ invalidate แบบ tag (updateTag / revalidateTag) มีประโยชน์กว่า revalidatePath เมื่อไร
ถ้า compile ใน Next.js 16 จะเกิดอะไรขึ้น
revalidateTag('posts')