React Server Components: Mental Model yang Akhirnya Masuk Akal

Foto oleh Ilham Malik

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.
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.
Inilah gambaran lengkapnya dalam satu tabel. Baris ketiga adalah yang sering dilupakan orang:
| Jenis komponen | Dieksekusi | Masuk bundle client? | Re-render? |
|---|---|---|---|
| Server Component (default) | Hanya server — saat build untuk route statis, saat request untuk yang dinamis | Tidak pernah | Tidak pernah; request baru menghasilkan render baru |
| Client Component (use client) | Server untuk HTML awal, lalu browser setelah hydration | Ya, plus semua yang diimpornya | Ya — state dan effect React normal |
| Komponen bersama (tanpa directive, tanpa API khusus server) | Tergantung siapa yang mengimpornya | Hanya jika modul client mengimpornya | Hanya 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.
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.
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
Tertahan di batas
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.
Aturan praktis saya, diterapkan dari atas ke bawah:
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.
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.