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

Server Components

React Server Component (RSC) รันบน server, ผลิต output ที่นั่น และ ไม่ส่ง code ไปที่ client browser ได้รับแค่ ผลลัพธ์ ไม่ใช่ตัว component นั่นให้ประโยชน์สองอย่าง: dependency ของ component (Markdown parser, database client) ไม่ไปอยู่ใน client bundle และ component เข้าถึง resource ของ server ได้ตรง ๆ

เพราะรันบน server, Server Component จึงเป็น async และ fetch data แบบ inline ได้ — ไม่ต้อง useEffect, ไม่ต้องมี loading state, ไม่ต้อง round-trip ไป client:

// Server Component — runs on the server. Note: it's an async function.
export default async function ProductPage({ id }) {
const product = await db.products.findById(id); // direct DB access, on the server
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
</article>
);
}

ไม่มี 'use client' ตรงนี้ component นี้จึงเป็น Server Component: await data แล้ว return JSX; client เห็นแค่ markup ที่ render แล้วเท่านั้น

Server Component ทำเรื่อง interactive ไม่ได้ — ไม่มี useState, ไม่มี onClick, ไม่มี browser API เพราะ code นั้นไม่เคยรันใน browser เมื่อคุณต้องการ interactivity คุณข้ามไปเป็น Client Component โดยใส่ directive 'use client' ที่ด้านบนของไฟล์:

'use client';
import { useState } from 'react';
// Client Component — ships to the browser, can use state and events.
export default function AddToCart({ productId }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>In cart: {count}</button>;
}

'use client' มาร์ค boundary: ไฟล์นั้นและทุกอย่างที่ไฟล์นั้น import จะกลายเป็น client code Server Component สามารถ render Client Component ได้ (server page ใส่ปุ่ม interactive ได้) แต่ทิศทางกลับกันไม่ได้แบบเดียวกัน — ทิศของ boundary สำคัญ

flowchart TB
  subgraph server["Server (never shipped)"]
    sc["Server Component
async, DB access, secrets"]
  end
  subgraph client["Client (shipped to browser)"]
    cc["Client Component
use client, useState, onClick"]
  end
  sc -->|renders + passes
serializable props| cc
server/client boundary (แผนภาพ)

Server Component ส่ง props ให้ Client Component แต่ props เหล่านั้นต้อง serializable — ต้องรอดจากการถูกส่งข้าม network ในรูปของ data:

  • ✅ string, number, boolean, plain object/array, JSX และ promise (client resolve ด้วย use())
  • ❌ function (event handler), class instance หรืออะไรที่ถือ live state — serialize แล้วส่งไม่ได้
// Server Component passes a promise across the boundary:
export default function Page() {
const commentsPromise = fetchComments(); // not awaited here
return <Comments commentsPromise={commentsPromise} />; // Client resolves it with use()
}

ข้อควรรู้ตามจริง: คุณไม่ได้ Server Components จาก react เปล่า ๆ + bundler RSC ต้องใช้ framework หรือ build setup ที่เข้าใจการแยก server/client และ serialize boundary ได้ — ในทางปฏิบัติคือ Next.js (App Router) และ framework อื่นที่รองรับ RSC แนวคิดเป็นของ React ส่วนการ wiring เป็นของ framework

ที่ผ่านมา data ไหลทางเดียว — server ไป client เท่านั้น อีกครึ่งของเรื่องคือการเรียก กลับ เข้าไปที่ server: Server Function คือ async function ที่ mark ด้วย 'use server' ซึ่ง client เรียกได้ตรง ๆ เหมือนเป็น function ธรรมดา ทั้งที่จริง ๆ มันสร้าง network request ตาม react.dev:

Add 'use server' at the top of an async function body to mark the function as callable by the client. We call these functions Server Functions. — react.dev/reference/rsc/use-server

คุณ mark function เดียวก็ได้ หรือใส่ 'use server' ที่หัวไฟล์ทั้งไฟล์ให้ทุก export กลายเป็น Server Function ก็ได้:

src/actions.ts
import { db } from './lib/db';
export async function addToCart(productId: string, quantity: number) {
'use server';
await db.cart.add({ productId, quantity });
}

เอา import เข้า Client Component แล้วส่งให้ useActionState ได้เหมือน action ฝั่ง client ธรรมดาในบทถัดไปทุกประการ — ต่างกันแค่ ที่ที่ function body รันจริง:

src/AddToCartButton.tsx
'use client';
import { useActionState } from 'react';
import { addToCart } from './actions';
type State = string | null;
// addToCart is a Server Function: calling it here serializes (productId,
// quantity) and sends them to the server, where the real function body runs.
async function submit(_prevState: State, formData: FormData): Promise<State> {
const productId = String(formData.get('productId'));
const quantity = Number(formData.get('quantity'));
await addToCart(productId, quantity);
return null;
}
export function AddToCartButton({ productId }: { productId: string }) {
const [error, formAction, isPending] = useActionState(submit, null);
return (
<form action={formAction}>
<input type="hidden" name="productId" value={productId} />
<input type="number" name="quantity" defaultValue={1} min={1} />
<button type="submit" disabled={isPending}>Add to cart</button>
{error && <p>{error}</p>}
</form>
);
}
src/lib/db.ts
export const db = {
cart: {
add: async (_args: { productId: string; quantity: number }) => {},
},
};

ทั้งสอง fence ข้างบน type-check ผ่านด้วย tsc เฉย ๆ — shape เป็น TypeScript ธรรมดา สิ่งที่มันทำไม่ได้คือรันจริง: 'use server' มีความหมายเฉพาะกับ bundler ที่รองรับ RSC ที่เขียน call ใหม่ให้กลายเป็น network request เท่านั้น Vite probe ของคอร์สนี้ไม่มี bundler แบบนั้น จึงไม่มีอะไรให้หน้านี้โชว์ การรันจริง ได้อย่างซื่อสัตย์ — โชว์ได้แค่ว่าโค้ด type ถูกต้อง ส่วน wiring จริงที่รันได้ (ครึ่งฝั่ง framework ของเรื่องนี้) ดูได้ที่คอร์ส Next.js Deep Dive บท What are Server Actions?

argument และ return value ของ Server Function ข้าม network เหมือนกัน กฎ serialization เดียวกับหัวข้อก่อนหน้าจึงใช้กับมันด้วย: primitive, plain object/array และ promise ไปได้ ส่วน function กับ class instance ไปไม่ได้

เมื่อ Server Component render, React ไม่ได้ส่ง HTML string ไปให้ client — มันส่ง คำอธิบาย element tree แบบ serialize แล้ว (เรียกกันแบบไม่เป็นทางการว่า “RSC payload” ผลิตด้วย wire format ที่ชื่อเล่นว่า “Flight”) React ฝั่ง client จะ reconstruct Client Component จากคำอธิบายนั้น แล้วเสียบ output ที่ Server Component render ไว้แล้วเข้าไป นี่คือสิ่งที่ทำให้ use() กับ promise ทำงานข้าม boundary ได้ และก็เป็นเหตุผลที่ boundary ต้องมีกฎ serialization ตั้งแต่แรก — payload คือ data ไม่ใช่ code request lifecycle เต็ม ๆ (routing, payload นี้, streaming, hydration) มีสอนครบใน Next.js Deep Dive บท request lifecycle คอร์สนี้แค่ต้องการให้คุณรู้ว่า payload มีอยู่จริง และทำไมมันถึงจำกัดว่าอะไรข้าม boundary ได้

มีสามอย่างที่เคลื่อนข้าม server/client line แต่ละอย่างมีกฎของตัวเอง: ผลลัพธ์ที่ render แล้ว ไหล server → client เป็น RSC payload (อนุญาตเสมอ นั่นคือประเด็นทั้งหมด), props ไหล server → client และต้อง serializable, การเรียก ไหล client → server ได้ทางเดียวคือผ่าน Server Function ซึ่ง argument และ return value ถูก serialize แบบเดียวกัน ไม่มีอะไรตรงนี้ที่จริง ๆ แล้วเกี่ยวกับ “code รันที่ไหน” แบบนามธรรม — มันเกี่ยวกับว่าอะไรต้องรอดจากการถูกแปลงเป็น data แล้วส่งผ่าน wire

ตัวเลือกได้อะไรต้นทุน
Server Componentเข้าถึง server resource ตรง ๆ, ไม่เสีย client bundle เลย, เป็น async ได้ไม่มี state, ไม่มี event, ไม่มี browser API — เลย
Client Component ('use client')interactive เต็มรูปแบบส่งไป browser; module tree ทั้งหมดกลายเป็น client bundle
Server Function ('use server')client สั่งงาน server ได้โดยไม่ต้องเขียน API route เองargument/return value ต้อง serializable เท่านั้น; ต้องมี framework ที่รองรับ RSC ถึงรันได้จริง
  • “'use client' แปลว่าโค้ดนี้รันฝั่ง client เท่านั้น” มันหมายถึงทิศตรงข้ามกับที่คนมักคิด: มัน mark module ว่า render จาก client ได้ export ของมันจึงกลายเป็น client reference ที่ Server Component render ได้ ไฟล์นี้ยังถูก execute ทุกที่ที่มัน render รวมถึงตอน pre-render บน server เพื่อทำ HTML แรก
  • “import function อะไรก็ได้เข้า Client Component แล้วมันกลายเป็น Server Function” เฉพาะ function ที่ body (หรือไฟล์) ขึ้นต้นด้วย 'use server' เท่านั้นที่กลายเป็น server reference ที่เรียกได้ import function ธรรมดาเข้า Client Component ก็แค่รันมันฝั่ง client เหมือน import อื่น ๆ
  • “RSC เป็นฟีเจอร์ React ที่ใช้ได้ใน React app ไหนก็ได้” แนวคิด (Server/Client Component, 'use server') เป็นส่วนหนึ่งของ React แต่ต้องมี framework ที่ implement RSC protocol — serialize payload, wire network call ให้ Server Function — เท่านั้นถึงจะรันได้จริง react เปล่า ๆ + Vite ทำไม่ได้

เมื่อเห็น 'use server' หรือ 'use client' ในโค้ดที่ AI สร้าง ให้เช็ค

  • 'use client' อยู่จริงในทุกไฟล์ที่เรียก useState, useEffect, หรือผูก event handler ไหม การไม่มีมันไม่ error ใน Vite probe นี้ (directive เป็นแค่ string สำหรับ tsc เฉย ๆ) แต่ framework RSC จริงจะ reject ไฟล์นั้นทันทีที่มันพยายามใช้ client-only API โดยไม่มี directive
  • Server Function ถูกเรียกจาก event handler หรือ form action — หรือถูกเรียกตรง ๆ ตอน render ไหม การเรียกตอน render เป็น side effect ใน render body ซึ่งผิดกฎ purity เหมือน side effect อื่น ๆ และยังหมายความว่า “request” ยิงทุกครั้งที่ render ไม่ใช่ตาม intent ของ user
  • argument ที่ส่งให้ Server Function serializable ไหม โค้ดที่ AI สร้างที่ส่ง callback, Map, หรือ class instance เป็น argument จะพังทันทีที่มันข้าม network boundary จริงใน framework จริง ทั้งที่ไม่มีอะไรใน Vite probe ของคอร์สนี้จับได้
  • Client Component รับ function prop จาก Server Component ไหม (ไม่ใช่จาก Client Component อื่น) ทิศทางนี้เฉพาะเจาะจงคือทิศที่ serialize ไม่ได้

🐛 หา bug ในโค้ดนี้

'use client'
import { addToCart } from './actions'
export function AddToCartButton({ productId }: { productId: string }) {
addToCart(productId, 1)
return <button>Add to cart</button>
}
เฉลย

addToCart ถูกเรียกตรง ๆ ใน body ของ component ไม่ใช่จาก event handler หรือ form action นั่นแปลว่ามันยิงทุก render ของ AddToCartButton — รวมถึง re-render ที่เกิดจาก state เปลี่ยนที่ไม่เกี่ยวข้องกันเลยที่อยู่สูงขึ้นไปใน tree — แทนที่จะยิงครั้งเดียวตอน user คลิก นี่คือปัญหา “side effect ระหว่าง render” แบบเดียวกับที่ Foundations สอนเรื่อง useState/mutation แค่เปลี่ยนจาก side effect เป็น network call วิธีแก้คือเรียกมันจาก onClick เท่านั้น หรือดีกว่านั้นคือ wire เป็น <form action={...}> แบบที่ useActionState ทำไปแล้วก่อนหน้านี้ในบทนี้

อะไรคือจุดเด่นของ React Server Component?
Server Component fetch data อย่างไร?
directive use client มาร์คอะไร?
prop ใดที่ส่งจาก Server Component ไป Client Component ไม่ได้?
ตาม react.dev การใส่ 'use server' ให้ async function ทำอะไร?
component นี้ผิดตรงไหน
'use client'
import { addToCart } from './actions'

export function AddToCartButton({ productId }: { productId: string }) {
  addToCart(productId, 1)
  return <button>Add to cart</button>
}
"RSC payload" (Flight) มีอะไรอยู่ข้างในจริง ๆ
การที่ tsc --noEmit ผ่านกับไฟล์คู่นี้ ยืนยันอะไรจริง ๆ?
// src/lib/db.ts
export const db = {
  cart: { add: async (_args: { productId: string; quantity: number }) => {} },
}

// src/actions.ts
import { db } from './lib/db'

export async function addToCart(productId: string, quantity: number) {
  'use server'
  await db.cart.add({ productId, quantity })
}