React 19: Server Components ในระบบ Production - คู่มือฉบับสมบูรณ์

เชี่ยวชาญ React 19 Server Components ในระบบ Production สถาปัตยกรรม รูปแบบการออกแบบ Streaming Caching และการเพิ่มประสิทธิภาพสำหรับแอปพลิเคชันที่มีประสิทธิภาพสูง

ภาพประกอบ React 19 Server Components แสดงสถาปัตยกรรม server-client ที่เชื่อมต่อกัน

Server Components ถือเป็นการพัฒนาที่สำคัญที่สุดใน React นับตั้งแต่การเปิดตัว Hooks ด้วย React 19 สถาปัตยกรรมนี้ได้เติบโตจนพร้อมสำหรับการใช้งานในระบบ Production ทำให้ Component สามารถทำงานได้โดยตรงบนเซิร์ฟเวอร์ในขณะที่ยังคงรักษาความสามารถในการโต้ตอบฝั่ง Client ไว้ได้

ข้อกำหนดเบื้องต้น

คู่มือนี้ถูกเขียนขึ้นโดยสมมติว่าท่านมีความคุ้นเคยกับ React และ Next.js App Router ตัวอย่างที่นำเสนอใช้ Next.js 14+ ซึ่งรองรับ React Server Components โดยค่าเริ่มต้น

ทำความเข้าใจสถาปัตยกรรม Server Components

Server Components (RSC) นำเสนอแนวคิดใหม่ โดย Component บางส่วนทำงานเฉพาะบนเซิร์ฟเวอร์ บางส่วนทำงานบน Client และทั้งสองสามารถอยู่ร่วมกันในโครงสร้าง Component เดียวกันได้ การแบ่งแยกนี้ช่วยเพิ่มประสิทธิภาพอย่างมากด้วยการลดขนาด JavaScript Bundle ที่ส่งไปยังเบราว์เซอร์

แนวคิดพื้นฐานตั้งอยู่บนความจริงที่ว่า Component จำนวนมากไม่จำเป็นต้องมีการโต้ตอบ Component ที่แสดงรายการบทความจากฐานข้อมูลสามารถทำงานได้ทั้งหมดบนเซิร์ฟเวอร์ มีเพียงองค์ประกอบที่ต้องโต้ตอบ (ปุ่ม แบบฟอร์ม แอนิเมชัน) เท่านั้นที่ต้องใช้ JavaScript ฝั่ง Client

app/articles/page.tsxtsx
// This component runs only on the server
// No JavaScript is sent to the client for this component
import { getArticles } from '@/lib/articles'
import ArticleCard from './ArticleCard'
import LikeButton from './LikeButton'

// async/await directly in the component
// Only possible with Server Components
export default async function ArticlesPage() {
  // Direct database call (no REST API needed)
  const articles = await getArticles()

  return (
    <main className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Recent Articles</h1>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {articles.map((article) => (
          // ArticleCard is also a Server Component
          <ArticleCard key={article.id} article={article}>
            {/* LikeButton is a Client Component (interactive) */}
            <LikeButton articleId={article.id} />
          </ArticleCard>
        ))}
      </div>
    </main>
  )
}

Directive "use client" ใช้สำหรับระบุอย่างชัดเจนว่า Component ใดต้องการ JavaScript บนเบราว์เซอร์

app/articles/LikeButton.tsxtsx
'use client'

// useState and interactive hooks require "use client"
import { useState, useTransition } from 'react'
import { likeArticle } from '@/actions/articles'

interface LikeButtonProps {
  articleId: string
  initialLikes?: number
}

export default function LikeButton({ articleId, initialLikes = 0 }: LikeButtonProps) {
  // Local state for optimistic UI
  const [likes, setLikes] = useState(initialLikes)
  const [isPending, startTransition] = useTransition()

  const handleLike = () => {
    // Immediate optimistic update
    setLikes((prev) => prev + 1)

    // Server Action to persist
    startTransition(async () => {
      await likeArticle(articleId)
    })
  }

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="flex items-center gap-2 px-3 py-1 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
    >
      <span>❤️</span>
      <span>{likes}</span>
    </button>
  )
}

สถาปัตยกรรมนี้ช่วยลดขนาด JavaScript Bundle ได้อย่างมาก โดยมีเพียงโค้ดของ LikeButton เท่านั้นที่ถูกส่งไปยัง Client ไม่ใช่ ArticlesPage หรือ ArticleCard

รูปแบบการประกอบ Server/Client

การประกอบระหว่าง Server และ Client Components เป็นไปตามกฎที่ชัดเจน Server Component สามารถ Import และ Render Client Component ได้ แต่ในทางกลับกันไม่สามารถทำได้โดยตรง ในการส่งเนื้อหาจากเซิร์ฟเวอร์ไปยัง Client Component รูปแบบ children คือทางออก

components/InteractiveWrapper.tsxtsx
'use client'

import { useState, ReactNode } from 'react'

interface InteractiveWrapperProps {
  children: ReactNode
  expandable?: boolean
}

// Client Component that wraps server content
export function InteractiveWrapper({ children, expandable = false }: InteractiveWrapperProps) {
  const [isExpanded, setIsExpanded] = useState(!expandable)

  if (!expandable) {
    return <div>{children}</div>
  }

  return (
    <div className="border rounded-lg overflow-hidden">
      <button
        onClick={() => setIsExpanded(!isExpanded)}
        className="w-full p-4 text-left font-medium bg-gray-50 hover:bg-gray-100"
      >
        {isExpanded ? '▼ Collapse' : '▶ Expand'}
      </button>

      {isExpanded && (
        <div className="p-4">
          {/* children can contain Server Components */}
          {children}
        </div>
      )}
    </div>
  )
}
app/dashboard/page.tsxtsx
// Server Component using the client wrapper
import { InteractiveWrapper } from '@/components/InteractiveWrapper'
import { getStats, getRecentActivity } from '@/lib/dashboard'

export default async function DashboardPage() {
  // Parallel server-side requests
  const [stats, activity] = await Promise.all([
    getStats(),
    getRecentActivity()
  ])

  return (
    <div className="space-y-6">
      {/* Stats in an expandable wrapper */}
      <InteractiveWrapper expandable>
        {/* This content is rendered server-side then passed to client */}
        <div className="grid grid-cols-3 gap-4">
          <StatCard title="Users" value={stats.users} />
          <StatCard title="Revenue" value={stats.revenue} />
          <StatCard title="Orders" value={stats.orders} />
        </div>
      </InteractiveWrapper>

      {/* Recent activity */}
      <InteractiveWrapper>
        <ActivityList activities={activity} />
      </InteractiveWrapper>
    </div>
  )
}

รูปแบบนี้ช่วยให้สามารถผสมผสานการโต้ตอบฝั่ง Client เข้ากับข้อมูลที่ Render บนเซิร์ฟเวอร์ได้โดยไม่ต้องทำซ้ำ Logic

การดึงข้อมูลและ Caching

Fetch ที่ถูกขยายโดย React

React 19 ขยาย API fetch ดั้งเดิมโดยอัตโนมัติเพื่อเพิ่มการขจัดความซ้ำซ้อนและ Caching Request ที่เหมือนกันภายใน Render เดียวกันจะถูกเรียกใช้เพียงครั้งเดียว

การดึงข้อมูลใน Server Components ทำได้โดยตรงด้วย async/await React จัดการการขจัด Request ที่ซ้ำซ้อนโดยอัตโนมัติ

lib/api.tstsx
// Centralized request configuration with caching
const API_BASE = process.env.API_URL

// Request with time-based revalidation
export async function getProducts() {
  const response = await fetch(`${API_BASE}/products`, {
    // Revalidate every hour
    next: { revalidate: 3600 }
  })

  if (!response.ok) {
    throw new Error('Failed to fetch products')
  }

  return response.json()
}

// Request without cache (real-time data)
export async function getCurrentUser() {
  const response = await fetch(`${API_BASE}/me`, {
    // No cache, always fresh
    cache: 'no-store'
  })

  if (!response.ok) {
    return null
  }

  return response.json()
}

// Request with tag for targeted invalidation
export async function getProduct(id: string) {
  const response = await fetch(`${API_BASE}/products/${id}`, {
    next: {
      tags: [`product-${id}`],
      revalidate: 3600
    }
  })

  if (!response.ok) {
    throw new Error('Product not found')
  }

  return response.json()
}

สำหรับการเข้าถึงฐานข้อมูลโดยตรง (Prisma, Drizzle) React Cache ร่วมกับ unstable_cache มอบความสามารถเดียวกัน

lib/db-queries.tstsx
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

// Cache categories (rarely modified)
export const getCategories = unstable_cache(
  async () => {
    return prisma.category.findMany({
      orderBy: { name: 'asc' }
    })
  },
  ['categories'], // Cache key
  {
    revalidate: 86400, // 24 hours
    tags: ['categories']
  }
)

// Cache products by category
export const getProductsByCategory = unstable_cache(
  async (categoryId: string) => {
    return prisma.product.findMany({
      where: { categoryId },
      include: { images: true },
      orderBy: { createdAt: 'desc' }
    })
  },
  ['products-by-category'],
  {
    revalidate: 3600,
    tags: ['products']
  }
)

// Cache invalidation after mutation
export async function createProduct(data: ProductInput) {
  const product = await prisma.product.create({ data })

  // Invalidate related caches
  revalidateTag('products')

  return product
}

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

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

Streaming และ Suspense เพื่อประสบการณ์ผู้ใช้ที่ดีที่สุด

Streaming ช่วยให้สามารถส่ง HTML ไปยังเบราว์เซอร์แบบค่อยเป็นค่อยไป แสดงส่วนที่พร้อมแล้วทันทีในขณะที่ส่วนอื่นยังคงโหลดอยู่ เมื่อใช้ร่วมกับ Suspense กลไกนี้ช่วยปรับปรุง Time to First Byte (TTFB) และประสบการณ์ผู้ใช้ได้อย่างเห็นได้ชัด

app/product/[id]/page.tsxtsx
import { Suspense } from 'react'
import { getProduct } from '@/lib/products'
import ProductDetails from './ProductDetails'
import ProductReviews from './ProductReviews'
import RecommendedProducts from './RecommendedProducts'
import { Skeleton } from '@/components/ui/Skeleton'

interface ProductPageProps {
  params: { id: string }
}

export default async function ProductPage({ params }: ProductPageProps) {
  // This request blocks initial render
  const product = await getProduct(params.id)

  return (
    <div className="container mx-auto py-8">
      {/* Immediate render with product data */}
      <ProductDetails product={product} />

      {/* Reviews load via streaming */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
        <Suspense fallback={<ReviewsSkeleton />}>
          {/* This async component will be streamed */}
          <ProductReviews productId={params.id} />
        </Suspense>
      </section>

      {/* Recommendations too */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-6">Similar Products</h2>
        <Suspense fallback={<ProductGridSkeleton count={4} />}>
          <RecommendedProducts productId={params.id} />
        </Suspense>
      </section>
    </div>
  )
}

// Skeleton for reviews
function ReviewsSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map((i) => (
        <div key={i} className="border rounded-lg p-4">
          <Skeleton className="h-4 w-32 mb-2" />
          <Skeleton className="h-4 w-full" />
          <Skeleton className="h-4 w-3/4" />
        </div>
      ))}
    </div>
  )
}
app/product/[id]/ProductReviews.tsxtsx
// Async Server Component that will be streamed
import { getProductReviews } from '@/lib/reviews'

interface ProductReviewsProps {
  productId: string
}

export default async function ProductReviews({ productId }: ProductReviewsProps) {
  // This request may take time
  // Component will be streamed when complete
  const reviews = await getProductReviews(productId)

  if (reviews.length === 0) {
    return (
      <p className="text-gray-500 italic">
        No reviews yet. Be the first to share your thoughts!
      </p>
    )
  }

  return (
    <div className="space-y-4">
      {reviews.map((review) => (
        <article key={review.id} className="border rounded-lg p-4">
          <div className="flex items-center gap-2 mb-2">
            <span className="font-medium">{review.author}</span>
            <span className="text-yellow-500">
              {'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
            </span>
          </div>
          <p className="text-gray-700">{review.content}</p>
          <time className="text-sm text-gray-500">
            {new Date(review.createdAt).toLocaleDateString('en-US')}
          </time>
        </article>
      ))}
    </div>
  )
}

เบราว์เซอร์จะได้รับโครงสร้าง HTML พร้อม Skeleton ก่อน จากนั้นเนื้อหาจริงจะถูกแทรกเข้ามาทีละส่วนผ่าน Streaming

Server Actions สำหรับ Mutations

Server Actions ช่วยให้สามารถเรียกใช้โค้ดฝั่งเซิร์ฟเวอร์จาก Client Component ได้โดยไม่ต้องสร้าง API Route วิธีการนี้ช่วยลดความซับซ้อนในการจัดการ Mutation ได้อย่างมาก

actions/cart.tstsx
'use server'

import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'

// Action to add to cart
export async function addToCart(productId: string, quantity: number = 1) {
  const user = await getCurrentUser()

  if (!user) {
    // Return structured error
    return { error: 'Login required', code: 'UNAUTHORIZED' }
  }

  try {
    // Check stock
    const product = await prisma.product.findUnique({
      where: { id: productId }
    })

    if (!product || product.stock < quantity) {
      return { error: 'Insufficient stock', code: 'OUT_OF_STOCK' }
    }

    // Add or update cart item
    await prisma.cartItem.upsert({
      where: {
        cartId_productId: {
          cartId: user.cartId,
          productId
        }
      },
      update: {
        quantity: { increment: quantity }
      },
      create: {
        cartId: user.cartId,
        productId,
        quantity
      }
    })

    // Invalidate cart page cache
    revalidatePath('/cart')

    return { success: true, message: 'Product added to cart' }
  } catch (error) {
    console.error('Add to cart error:', error)
    return { error: 'An error occurred', code: 'SERVER_ERROR' }
  }
}

// Action to remove from cart
export async function removeFromCart(itemId: string) {
  const user = await getCurrentUser()

  if (!user) {
    return { error: 'Login required' }
  }

  await prisma.cartItem.delete({
    where: { id: itemId, cart: { userId: user.id } }
  })

  revalidatePath('/cart')

  return { success: true }
}
components/AddToCartButton.tsxtsx
'use client'

import { useTransition } from 'react'
import { addToCart } from '@/actions/cart'
import { toast } from '@/components/ui/toast'

interface AddToCartButtonProps {
  productId: string
}

export function AddToCartButton({ productId }: AddToCartButtonProps) {
  const [isPending, startTransition] = useTransition()

  const handleClick = () => {
    startTransition(async () => {
      const result = await addToCart(productId)

      if (result.error) {
        toast.error(result.error)
        return
      }

      toast.success(result.message)
    })
  }

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className="w-full py-3 px-6 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
    >
      {isPending ? (
        <span className="flex items-center justify-center gap-2">
          <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
          Adding...
        </span>
      ) : (
        'Add to Cart'
      )}
    </button>
  )
}
การตรวจสอบข้อมูลฝั่งเซิร์ฟเวอร์

ควรตรวจสอบความถูกต้องของข้อมูลใน Server Actions เสมอ การตรวจสอบฝั่ง Client สามารถถูกข้ามได้ ใช้ Zod หรือไลบรารีที่คล้ายกันเพื่อให้การตรวจสอบมีความน่าเชื่อถือ

การจัดการข้อผิดพลาดและ Error Boundaries

React 19 ปรับปรุงการจัดการข้อผิดพลาดใน Server Components โดย Error Boundaries ทำงานในลักษณะเดียวกับ Client Components

app/products/error.tsxtsx
'use client'

// Error Boundary for /products segment
interface ErrorProps {
  error: Error & { digest?: string }
  reset: () => void
}

export default function ProductsError({ error, reset }: ErrorProps) {
  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] text-center">
      <div className="p-6 bg-red-50 rounded-lg max-w-md">
        <h2 className="text-xl font-bold text-red-800 mb-2">
          Loading Error
        </h2>
        <p className="text-red-600 mb-4">
          Unable to load products. Please try again.
        </p>
        {/* Display digest for debugging */}
        {error.digest && (
          <p className="text-sm text-gray-500 mb-4">
            Reference: {error.digest}
          </p>
        )}
        <button
          onClick={reset}
          className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
        >
          Try Again
        </button>
      </div>
    </div>
  )
}
app/products/loading.tsxtsx
// Loading UI during initial load
export default function ProductsLoading() {
  return (
    <div className="container mx-auto py-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid grid-cols-3 gap-6">
        {[1, 2, 3, 4, 5, 6].map((i) => (
          <div key={i} className="border rounded-lg p-4">
            <div className="aspect-square bg-gray-200 rounded animate-pulse mb-4" />
            <div className="h-4 bg-gray-200 rounded animate-pulse mb-2" />
            <div className="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  )
}

สำหรับการจัดการข้อผิดพลาดที่ละเอียดยิ่งขึ้นใน Component เฉพาะ สามารถใช้ Component ErrorBoundary ได้โดยตรง

components/ErrorBoundary.tsxtsx
'use client'

import { Component, ReactNode } from 'react'

interface Props {
  children: ReactNode
  fallback?: ReactNode
}

interface State {
  hasError: boolean
  error?: Error
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Log error to monitoring service
    console.error('ErrorBoundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 bg-red-50 rounded-lg">
          <p className="text-red-600">An error occurred</p>
        </div>
      )
    }

    return this.props.children
  }
}

การเพิ่มประสิทธิภาพสำหรับ Production

เทคนิคหลายประการช่วยเพิ่มประสิทธิภาพของ Server Components ในระบบ Production

app/layout.tsxtsx
import { Suspense } from 'react'
import { headers } from 'next/headers'

// Preload critical data
export const dynamic = 'force-dynamic'

export default async function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {/* Header streamed independently */}
        <Suspense fallback={<HeaderSkeleton />}>
          <Header />
        </Suspense>

        <main>{children}</main>

        {/* Footer can be static */}
        <Footer />
      </body>
    </html>
  )
}
lib/prefetch.tstsx
// Data prefetching for links
import { preload } from 'react-dom'

export function prefetchProduct(productId: string) {
  // Prefetch images
  preload(`/api/products/${productId}/image`, { as: 'image' })
}

// Usage in a component
// <Link href={`/products/${id}`} onMouseEnter={() => prefetchProduct(id)}>
next.config.jstsx
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Build optimizations
  experimental: {
    // Enable Partial Prerendering (PPR)
    ppr: true,
    // Optimize package imports
    optimizePackageImports: ['lucide-react', '@radix-ui/react-icons']
  },

  // Image configuration
  images: {
    formats: ['image/avif', 'image/webp'],
    remotePatterns: [
      { hostname: 'cdn.example.com' }
    ]
  }
}

module.exports = nextConfig

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

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

บทสรุป

React 19 Server Components เปลี่ยนแปลงวิธีการสร้างแอปพลิเคชัน React อย่างรากฐาน ประเด็นสำคัญที่ควรจดจำ:

  • แยก Server/Client อย่างชัดเจน: ใช้ "use client" เฉพาะสำหรับ Component ที่ต้องโต้ตอบเท่านั้น
  • ดึงข้อมูลโดยตรง: ใช้ async/await ใน Component ได้เลย ไม่จำเป็นต้องใช้ useEffect หรือ API Route
  • Streaming ด้วย Suspense: แสดงผลแบบค่อยเป็นค่อยไปเพื่อประสบการณ์ผู้ใช้ที่ดีขึ้น
  • Server Actions: ลดความซับซ้อนของ Mutation โดยไม่ต้องสร้าง API Endpoint
  • Caching อัจฉริยะ: ใช้ revalidate และ tags เพื่อเพิ่มประสิทธิภาพ
  • การประกอบที่ยืดหยุ่น: ใช้รูปแบบ children เพื่อผสมผสาน Server และ Client Components

สถาปัตยกรรมนี้ช่วยให้สร้างแอปพลิเคชันที่มีประสิทธิภาพสูงขึ้นโดยใช้ JavaScript ฝั่ง Client น้อยลง พร้อมทั้งลดความซับซ้อนของโค้ดอย่างมาก การเปลี่ยนผ่านไปสู่ Server Components เป็นการลงทุนที่ให้ผลตอบแทนอย่างรวดเร็วทั้งในด้านประสิทธิภาพและความสามารถในการบำรุงรักษา

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

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

แท็ก

#react 19
#server components
#rsc
#performance
#next.js

แชร์

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