React Server Components ในโปรดักชัน: รูปแบบและกับดัก

React Server Components ในโปรดักชัน: รูปแบบที่ผ่านการพิสูจน์ แอนตี้แพทเทิร์นที่พบบ่อย และกลยุทธ์การดีบักสำหรับแอป Next.js 15 ที่มั่นคง

รูปแบบและกับดักของ React Server Components ในโปรดักชัน

React Server Components (RSC) เปลี่ยนวิธีทำงานของการเรนเดอร์ฝั่งเซิร์ฟเวอร์ใน Next.js 15 อย่างพื้นฐาน แต่การนำไปใช้ในโปรดักชันเผยให้เห็นกับดักที่เอกสารทางการอาจไม่ได้ครอบคลุมเสมอไป บทความนี้อธิบายแยกแยะรูปแบบที่ใช้งานได้ดี รูปแบบที่พังง่าย และวิธีวินิจฉัยปัญหาก่อนที่จะไปถึงโปรดักชัน

Server Components และ Client Components

Server Component ทำงานเฉพาะบนเซิร์ฟเวอร์และส่ง JavaScript ไปยังเบราว์เซอร์เป็นศูนย์ ส่วน Client Component (ทำเครื่องหมายด้วย "use client") ทำงานทั้งสองฝั่ง กฎคือ ให้ Client Components มีขนาดเล็กที่สุดและอยู่ตำแหน่งต่ำที่สุดในต้นไม้คอมโพเนนต์

ขอบเขตเซิร์ฟเวอร์-ไคลเอนต์: เข้าใจรูปแบบ boundary

กับดัก RSC ที่พบบ่อยที่สุดคือเรื่องขอบเขตระหว่าง Server และ Client Components เมื่อคอมโพเนนต์ใดมีไดเรกทีฟ "use client" แล้ว ลูกที่ถูก import เข้ามาทั้งหมดจะกลายเป็น Client Components ตามไปด้วย แม้จะไม่มีไดเรกทีฟก็ตาม

ProductPage.tsx (Server Component)tsx
import { ProductDetails } from './ProductDetails'
import { AddToCartButton } from './AddToCartButton'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)

  return (
    <div>
      {/* Server Component: เข้าถึง DB โดยตรง */}
      <ProductDetails product={product} />
      {/* Client Component: ความสามารถโต้ตอบที่แยกออกมา */}
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  )
}
AddToCartButton.tsx (Client Component)tsx
'use client'

import { useState } from 'react'

export function AddToCartButton({ productId, price }: { productId: string; price: number }) {
  const [adding, setAdding] = useState(false)

  async function handleAdd() {
    setAdding(true)
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId, quantity: 1 }),
    })
    setAdding(false)
  }

  return (
    <button onClick={handleAdd} disabled={adding}>
      {adding ? 'กำลังเพิ่ม...' : `เพิ่มในตะกร้า — $${price}`}
    </button>
  )
}

รูปแบบสำคัญ: ส่งข้อมูลในรูป props ที่ serialize ได้ จาก Server Component ไปยัง Client Component ฟังก์ชัน คลาส และอ็อบเจกต์ Date ไม่สามารถข้ามขอบเขตนี้ได้

แอนตี้แพทเทิร์น: Wrapper แบบ Client Component ที่ไม่จำเป็น

ความผิดพลาดที่พบบ่อยคือสร้าง Client Component ที่ห่อหุ้มลูกที่เป็น Server Component ทำให้ทั้งสาขาย่อยถูกบังคับให้กลายเป็นฝั่งไคลเอนต์

PageWrapper.tsx — แอนตี้แพทเทิร์นtsx
'use client'

import { useState } from 'react'

// เนื้อหาลูกทั้งหมดถูกย้ายไปฝั่งไคลเอนต์
export function PageWrapper({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')
  return (
    <div className={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        สลับธีม
      </button>
      {children}
    </div>
  )
}
children ในรูปแบบ slot

วิธีแก้: ส่ง Server Components ผ่าน children (รูปแบบ slot) ลูกที่ถูกส่งเป็น props จะยังเป็น Server Components แม้ว่า parent จะเป็น Client Component โค้ดข้างต้นทำงานได้อย่างถูกต้องตราบใดที่ children มาจาก parent ที่เป็น Server Component

layout.tsx (Server Component)tsx
import { PageWrapper } from './PageWrapper'
import { HeavyServerContent } from './HeavyServerContent'

export default function Layout() {
  return (
    <PageWrapper>
      {/* ยังคงเป็น Server Component แม้มี wrapper ที่เป็นไคลเอนต์ */}
      <HeavyServerContent />
    </PageWrapper>
  )
}

รูปแบบ composition นี้รักษาประโยชน์ของการเรนเดอร์ฝั่งเซิร์ฟเวอร์สำหรับเนื้อหาหนัก ในขณะที่เปิดให้มีปฏิสัมพันธ์ในระดับ wrapper

การจัดการข้อมูลแบบ async: รูปแบบ fetch ในคอมโพเนนต์

React 19 และ Next.js 15 รองรับ async/await ใน Server Components โดยตรง รูปแบบนี้ทำให้การดึงข้อมูลง่ายขึ้นเมื่อเทียบกับวิธี getServerSideProps แบบเดิม

UserProfile.tsx (Server Component)tsx
import { cache } from 'react'

// ลดการเรียกซ้ำที่เหมือนกันภายในการเรนเดอร์เดียวกัน
const getUser = cache(async (userId: string) => {
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    next: { revalidate: 3600 }, // แคชเป็นเวลา 1 ชั่วโมง
  })
  if (!res.ok) throw new Error('User not found')
  return res.json()
})

export default async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId)

  return (
    <section>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>เป็นสมาชิกตั้งแต่ {new Date(user.createdAt).toLocaleDateString('th-TH')}</p>
    </section>
  )
}

สามจุดสำคัญ:

  • cache() ของ React ลดการเรียกซ้ำที่เหมือนกันในการเรนเดอร์เซิร์ฟเวอร์เดียว
  • next: { revalidate } ควบคุมระยะเวลาแคชฝั่ง Next.js
  • ข้อผิดพลาดในรูปแบบ Server Component แบบ async จะกระตุ้น error.tsx ที่อยู่ใกล้ที่สุด

กับดัก serialize: สิ่งที่ข้ามขอบเขตไม่ได้

ข้อมูลที่แลกเปลี่ยนระหว่าง Server และ Client Components ต้อง serialize เป็น JSON ได้ ต่อไปนี้คือสิ่งที่ทำให้เกิดข้อผิดพลาดเงียบหรือ crash

tsx
// กับดัก: ส่งประเภทที่ serialize ไม่ได้
// ฟังก์ชัน — ใช้ไม่ได้
<ClientComp onSubmit={async (data) => { /* server action */ }} />
// ใช้ Server Action ที่ import เข้ามาแทน
import { submitForm } from '@/lib/actions/form'
<ClientComp onSubmit={submitForm} />

// อ็อบเจกต์ Date — ใช้ไม่ได้
<ClientComp createdAt={new Date()} />
// สตริง ISO — ใช้ได้
<ClientComp createdAt={new Date().toISOString()} />

// Map, Set, RegExp — ใช้ไม่ได้
<ClientComp data={new Map([['key', 'value']])} />
// อ็อบเจกต์ธรรมดาหรืออาเรย์ — ใช้ได้
<ClientComp data={{ key: 'value' }} />

Server Actions (ฟังก์ชันที่มี "use server") คือข้อยกเว้น สามารถส่งเป็น props ไปยัง Client Component ได้ เพราะ Next.js แปลงให้เป็น HTTP endpoint

พร้อมที่จะพิชิตการสัมภาษณ์ React / Next.js แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

Streaming และ Suspense: รูปแบบโหลดแบบค่อยเป็นค่อยไป

SSR streaming ร่วมกับ Suspense ส่ง HTML ไปยังเบราว์เซอร์แบบทยอยส่ง รูปแบบที่ดีที่สุดใช้ Suspense boundary ละเอียดล้อมรอบทุกส่วนแบบ async

DashboardPage.tsx (Server Component)tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { RecentOrders } from './RecentOrders'
import { UserStats } from './UserStats'

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  )
}

แต่ละส่วนโหลดเป็นอิสระ ถ้า RevenueChart ใช้เวลา 3 วินาที และ UserStats ใช้ 200 มิลลิวินาที ส่วนสถิติจะปรากฏทันทีโดยไม่ต้องรอกราฟ

Suspense และ SEO

เนื้อหาภายใน Suspense boundary ถูกเรนเดอร์ฝั่งเซิร์ฟเวอร์และอยู่ใน HTML ตั้งต้น Crawler มองเห็นเนื้อหาทั้งหมด Streaming ส่งผลเฉพาะกับความเร็วในการส่งไปยังเบราว์เซอร์ ไม่ส่งผลต่อการมองเห็น SEO

ดีบักในโปรดักชัน: ตามรอยปัญหา RSC

ข้อผิดพลาด RSC มักดูเข้าใจยาก สามเทคนิควินิจฉัยที่ใช้ได้จริงในโปรดักชัน

1. ระบุ hydration mismatch

debug-hydration.tsxtsx
'use client'

import { useEffect, useState } from 'react'

export function HydrationDebug() {
  const [isClient, setIsClient] = useState(false)

  useEffect(() => {
    setIsClient(true)
  }, [])

  if (process.env.NODE_ENV !== 'development') return null

  return (
    <div style={{ position: 'fixed', bottom: 0, right: 0, padding: '4px 8px', fontSize: 12 }}>
      {isClient ? 'Client' : 'Server'}
    </div>
  )
}

2. บันทึกล็อก payload ของ RSC

ใน Next.js 15 เปิดใช้การล็อก RSC ใน next.config.ts:

next.config.tstypescript
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true, // แสดง URL fetch แบบเต็ม
    },
  },
}

export default nextConfig

3. ตรวจสอบขนาด payload

payload RSC ขนาดเกิน (> 128 KB) ทำให้ประสิทธิภาพลดลง ควรเฝ้าดูคำขอเครือข่ายที่มี content type text/x-component ใน DevTools

รูปแบบขั้นสูง: composition กับ Server Actions

Server Actions ผสมกับ Server Components สร้างรูปแบบ CQRS ตามธรรมชาติ: อ่านข้อมูลที่เซิร์ฟเวอร์ (RSC) เขียนข้อมูลผ่าน actions

TodoList.tsx (Server Component)tsx
import { getTodos } from '@/lib/services/todo'
import { TodoForm } from './TodoForm'
import { deleteTodo } from '@/lib/actions/todo'

export default async function TodoList() {
  const todos = await getTodos()

  return (
    <div>
      <TodoForm />
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            <form action={deleteTodo}>
              <input type="hidden" name="id" value={todo.id} />
              <button type="submit">ลบ</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  )
}
actions/todo.tstsx
'use server'

import { revalidatePath } from 'next/cache'
import { TodoService } from '@/lib/services/todo'

export async function deleteTodo(formData: FormData) {
  const id = formData.get('id') as string
  await TodoService.delete(id)
  revalidatePath('/todos')
}

การเรียก revalidatePath กระตุ้นการเรนเดอร์ Server Component ใหม่ด้วยข้อมูลที่อัปเดต โดยไม่ต้องโหลดหน้าใหม่ทั้งหมด

หากต้องการเตรียมสัมภาษณ์ในหัวข้อเหล่านี้อย่างลึกซึ้งยิ่งขึ้น ขอแนะนำให้ดูโมดูล Next.js Server Actions และโมดูล Next.js Data Fetching บน SharpSkill เอกสารทางการของ React ครอบคลุมข้อกำหนด Server Components ทั้งหมด

สรุป

  • ให้ Client Components มีขนาดเล็กและแยกอยู่ที่ฐานล่างของต้นไม้คอมโพเนนต์
  • ใช้รูปแบบ slot (children) เพื่อรักษา Server Components ภายใน wrapper ที่เป็นไคลเอนต์
  • ตรวจสอบเสมอว่า props ที่ข้ามขอบเขตเซิร์ฟเวอร์-ไคลเอนต์สามารถ serialize ได้
  • วาง Suspense boundary ละเอียดรอบทุกส่วน async ที่เป็นอิสระ
  • ตรวจสอบขนาด payload RSC ในโปรดักชัน (เป้าหมาย < 128 KB)
  • รวม Server Components (อ่าน) และ Server Actions (เขียน) เพื่อรูปแบบ CQRS ตามธรรมชาติ
  • ใช้ cache() ของ React เพื่อลดการเรียกซ้ำของคำขอภายในการเรนเดอร์เซิร์ฟเวอร์เดียว

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#react server components
#next.js 15
#rsc patterns
#production
#react 19

แชร์

บทความที่เกี่ยวข้อง