Skip to main content
BlogWeb Dev

React Server Components: Mental Model yang Akhirnya Masuk Akal

Apa yang sebenarnya berjalan di mana pada React Server Components: batas module graph, tembok serialisasi, dan aturan empat langkah kapan menambah use client.

Matthews Wong

Software Engineer

April 202610 menit baca
React Server Components: Mental Model yang Akhirnya Masuk Akal

Foto oleh Ilham Malik

Saya sudah meng-onboarding beberapa developer ke proyek App Router dalam dua tahun terakhir, dan semuanya menabrak tembok yang sama: React Server Components terasa sewenang-wenang sampai mental modelnya klik. Mereka menaburkan use client pada apa pun yang error, melihat bundle membengkak, lalu menyimpulkan RSC hanyalah Next.js yang menyusahkan. Padahal modelnya sederhana — hanya saja sungguh berbeda dari semua yang React ajarkan selama satu dekade.

Artikel ini adalah penjelasan yang sekarang saya berikan saat onboarding, yang sudah teruji berulang kali: apa yang berjalan di mana dan kapan, mengapa batasnya hidup di module graph dan bukan di pohon komponen, apa yang selamat melewati tembok serialisasi antara server dan client, dan aturan keputusan empat langkah untuk directive use client yang mengakhiri tebak-tebakan.

Pertama, Lupakan Dulu: RSC Bukan SSR

Kebingungan paling umum adalah menyamakan Server Components dengan server-side rendering. Keduanya ortogonal. SSR mengambil aplikasi client dan menjalankannya lebih dulu di server untuk menghasilkan HTML awal — lalu tetap mengirim semua kode komponen, karena client harus melakukan hydration dan mengambil alih. Setiap komponen tetap membayar biaya bundle-nya. Client Components di App Router masih bekerja persis seperti ini: mereka dirender di server untuk HTML awal dan dirender lagi di browser.

Server Components adalah spesies berbeda. Kodenya tidak pernah meninggalkan server. Browser menerima hasil rendernya — deskripsi UI yang sudah diserialisasi — tapi tidak pernah menerima fungsi yang menghasilkannya, maupun import-nya. Parser markdown, syntax highlighter, atau database client yang diimpor Server Component menambah tepat nol byte ke bundle client. Dan karena kodenya tidak pernah sampai ke browser, Server Components tidak pernah re-render. Mereka berjalan sekali per request, atau sekali saat build, lalu selesai. Tanpa hydration, tanpa dependency array, tanpa stale closure, tanpa ritual memoization.

Apa yang Sebenarnya Berjalan di Mana

Inilah gambaran lengkapnya dalam satu tabel. Baris ketiga adalah yang sering dilupakan orang:

Jenis komponenDieksekusiMasuk bundle client?Re-render?
Server Component (default)Hanya server — saat build untuk route statis, saat request untuk yang dinamisTidak pernahTidak pernah; request baru menghasilkan render baru
Client Component (use client)Server untuk HTML awal, lalu browser setelah hydrationYa, plus semua yang diimpornyaYa — state dan effect React normal
Komponen bersama (tanpa directive, tanpa API khusus server)Tergantung siapa yang mengimpornyaHanya jika modul client mengimpornyaHanya saat dirender sebagai Client Component

Baris ketiga itu adalah kehalusan langsung dari dokumentasi React: sebuah komponen tidak secara intrinsik merupakan komponen server atau client. File Button yang sama, tanpa directive, dirender sebagai Server Component saat sebuah page mengimpornya dan sebagai Client Component saat sesuatu di bawah batas use client mengimpornya. Directive menandai batas, bukan identitas.

Batasnya Hidup di Module Graph, Bukan di Render Tree

Inilah insight yang mengurai sebagian besar kebingungan. Saat Anda menaruh use client di atas sebuah file, Anda tidak sedang melabeli satu komponen — Anda mendeklarasikan bahwa modul ini dan setiap modul yang diimpornya, secara transitif, milik bundle client. Batas digambar lewat statement import, bukan lewat penyarangan JSX.

// The boundary lives in the MODULE graph, not the render tree.

// sidebar.tsx — Client Component (and so is everything it imports)
"use client"
import { HeavyChartLib } from "heavy-chart-lib" // now in the client bundle too

// page.tsx — Server Component (default, no directive)
import { Sidebar } from "./sidebar"
import { db } from "@/lib/db" // never reaches the browser

export default async function Page() {
  const stats = await db.stats.findMany() // direct DB access, no API layer
  return <Sidebar stats={stats} />        // stats must be serializable
}

// The escape hatch: pass Server Components THROUGH a client
// boundary as children. The client shell re-renders;
// the server-rendered children do not.
// layout.tsx (server)
import { ThemeProvider } from "./theme-provider" // "use client"

export default function Layout({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>
  //                     children stay server-rendered
}

Pola children pada snippet itu adalah konsekuensi paling praktis. Provider use client tidak menyeret children JSX-nya ke client, karena layout — sebuah Server Component — sudah merendernya dan meneruskan hasil jadinya sebagai prop. Begitulah seluruh aplikasi bisa berada di dalam theme provider, auth context, dan wrapper analytics sementara page-nya sendiri tetap server-only. Saat navbar portfolio ini butuh scroll listener, saya tidak mengonversi layout; saya memisahkan komponen client ScrollHeader dua puluh baris dan meneruskan sisanya sebagai children.

Tembok Serialisasi

Props yang menyeberang dari Server Component ke Client Component berjalan lewat jaringan sebagai bagian dari payload RSC, jadi harus selamat dari serialisasi. React menegakkan daftar ketat, dan menghafalnya mencegah satu kategori penuh error runtime:

Bisa menyeberangi batas

  • Primitif: string, number, boolean, null, undefined, bigint
  • Object polos, array, Map, Set, Date, TypedArray
  • JSX — hasil render server yang diteruskan sebagai children atau props
  • Promise (di-stream dan di-await dengan hook use) dan Server Functions

Tertahan di batas

  • Fungsi biasa dan callback event handler
  • Instance class — entity ORM Anda, object Decimal, instance Dayjs
  • Symbol yang tidak terdaftar global
  • Apa pun yang punya method yang perlu dipanggil client

Pembatasan instance class paling sering menggigit di proyek nyata. Prisma Decimal atau tanggal Dayjs akan melempar error begitu menyentuh prop client. Solusinya adalah langkah pemetaan di batas — konversi ke number polos dan string ISO di Server Component. Orang security akan bilang pembatasan ini adalah fitur: ia memaksa Anda memutuskan field demi field data apa yang benar-benar meninggalkan server, alih-alih membocorkan seluruh entity ORM ke HTML.

Kapan Menambah use client: Aturan Empat Langkah

Aturan praktis saya, diterapkan dari atas ke bawah:

  1. Default ke server. Tulis setiap komponen baru tanpa directive. Kalau berhasil compile dan render, selesai — biayanya nol di bundle.
  2. Waspadai tiga pemicu: Anda butuh state atau effect, Anda butuh API browser seperti localStorage atau IntersectionObserver, atau Anda butuh event handler. Hanya ini yang membenarkan directive.
  3. Dorong batasnya ke bawah. Jangan tandai page atau section — ekstrak leaf terkecil yang butuh interaktivitas dan tandai itu saja. Halaman produk dengan satu tombol favorit butuh komponen client sepuluh baris, bukan page client.
  4. Teruskan hasil server lewat children. Jika wrapper client butuh konten hasil render server di dalamnya, terima sebagai children alih-alih mengimpornya — import menyeret modul melintasi batas, children tidak.

Directive ini menular lewat import. Satu use client yang ceroboh di atas barrel file bersama bisa diam-diam menarik separuh component library Anda — beserta dependensinya — ke bundle client. Audit apa yang diimpor modul client Anda, bukan hanya di mana Anda menulis directive-nya.

Miskonsepsi yang Layak Dibasmi

  • Server Components tidak menggantikan API untuk mutasi — mereka read-only secara desain. Operasi tulis lewat Server Functions, mekanisme terpisah dengan model keamanannya sendiri.
  • use server bukan kebalikan dari use client. Ia menandai fungsi yang bisa dipanggil dari client — endpoint RPC — bukan komponen yang dirender di server. Server Components tidak butuh directive sama sekali.
  • Client Component bukan client-only. Ia tetap dirender di server untuk HTML awal. Kalau ia membaca window di module scope, ia crash saat SSR — lindungi API browser di dalam effect.
  • Server Components tidak lebih lambat karena roundtrip server. Navigasi mengambil payload RSC yang ringkas, dan React menggabungkannya ke tree yang ada sementara state client di komponen yang tak tersentuh tetap hidup.

Modelnya dalam Satu Paragraf

Dua dunia, satu tree. Server Components berjalan sekali, di server, dengan akses langsung ke data, dan tidak mengirim apa pun; Client Components melakukan hydration dan hidup di browser dengan state dan event. Directive use client menggambar perbatasan melalui module graph, semua yang menyeberanginya harus bisa diserialisasi, dan JSX yang diteruskan sebagai children adalah kantong diplomatik yang membuat konten hasil render server melewati wilayah client tanpa tersentuh.

Begitu paragraf itu menjadi intuisi alih-alih hafalan, App Router berhenti terasa seperti ladang ranjau. Anda berhenti menabur directive untuk membungkam error dan mulai menempatkan batas dengan sengaja — dan bundle analyzer Anda akan menunjukkan bedanya dalam kilobyte.

Jalankan analisis bundle sebelum dan sesudah memindahkan batas use client satu level ke bawah. Pertama kali Anda melihat empat puluh kilobyte library charting hilang dari shared chunk, model module-graph berhenti jadi sesuatu yang abstrak.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa perbedaan utama antara React Server Components dan server-side rendering (SSR)?

SSR menjalankan aplikasi client lebih dulu di server untuk menghasilkan HTML awal, tetapi tetap mengirim semua kode komponen ke browser untuk hydration. Server Components berbeda secara fundamental: kodenya tidak pernah sampai ke browser sama sekali, sehingga library yang diimpor seperti parser markdown atau database client tidak menambah satu byte pun ke bundle client, dan komponen ini tidak pernah re-render.

Di mana tepatnya batas use client berada — di pohon komponen atau di module graph?

Batasnya hidup di module graph, bukan di render tree. Menaruh use client di atas sebuah file berarti mendeklarasikan bahwa modul itu dan setiap modul yang diimpornya secara transitif milik bundle client. Artinya, satu use client yang ceroboh di barrel file bersama bisa diam-diam menarik seluruh component library ke bundle, meski penyarangan JSX-nya terlihat tidak bermasalah.

Tipe data apa yang bisa melewati batas dari Server Component ke Client Component sebagai props?

Hanya nilai yang bisa diserialisasi yang bisa melewati batas: primitif, object dan array polos, Map, Set, Date, TypedArray, JSX yang diteruskan sebagai children atau props, Promise, dan Server Functions. Fungsi biasa, instance class (seperti entity ORM, object Decimal, atau instance Dayjs), dan Symbol tertahan di batas. Solusi untuk instance class adalah mengonversinya ke number polos dan string ISO di dalam Server Component sebelum diteruskan sebagai props.

Bagaimana pola children memungkinkan konten hasil render server melewati wrapper Client Component?

Provider Client Component tidak menyeret children JSX-nya ke bundle client karena Server Component induknya sudah merendernya dan meneruskan hasil jadinya sebagai prop. Artinya, seluruh aplikasi bisa dibungkus theme provider, auth context, atau analytics wrapper yang merupakan Client Components, sementara page-nya tetap server-only — karena children berjalan sebagai output JSX yang sudah diserialisasi, bukan sebagai modul yang diimpor.

Apa aturan empat langkah untuk memutuskan kapan menambahkan directive use client?

Pertama, default ke server — tulis setiap komponen tanpa directive dan tambahkan hanya jika diperlukan. Kedua, tambahkan hanya saat butuh state atau effect, API browser, atau event handler. Ketiga, dorong batasnya sejauh mungkin ke bawah dengan mengekstrak leaf interaktif terkecil, bukan menandai seluruh page atau section. Keempat, teruskan konten hasil render server melalui prop children alih-alih import, karena import menyeret modul melintasi batas sementara children tidak.