← semua materi
15:00 Teknis · 30 Oct 2026 · 15:04

📚 React Server Components — model render yang berubah

Status versi (terverifikasi): - React 19.3.0 rilis 9 Sep 2026; patch terakhir 19.3.1 (14 Okt 2026) — react.dev/versions - Next.js 16.4.0 rilis 6 Okt 2026; patch 16.4.3 (16 Okt 2026) — nextjs.org/blog/next-16-4 - RSC di React 19 stabil; API internal bundler-nya tidak ikut semver (dokumentasi resmi React) → pin versi React kalau bikin tooling/framework sendiri.


🎯 Tujuan Belajar

Bisa, besok: 1. Memutuskan komponen mana server, mana client — bukan berdasarkan "kebiasaan", tapi berdasarkan di mana data dan interaktivitas dibutuhkan. 2. Membaca dan memperbaiki error serialisasi RSC boundary. 3. Memakai 'use cache' + cacheLife untuk caching eksplisit, dan tahu kapan itu merugikan. 4. Menghindari pola yang bikin bundle client membengkak diam-diam.


🏗️ Arsitektur & Alur Teknis

DIKETAHUI (dari dokumen resmi React & Next.js):

RSC bukan SSR. Dua fase berbeda:

Fase Jalan di mana Output
Server Component render Node/edge runtime, sebelum bundling Payload RSC (bukan HTML)
SSR Server HTML dari payload tadi
Hydration Browser Client Component jadi interaktif

Konsekuensi penting: - Kode Server Component tidak pernah masuk bundle client. Library berat (marked, sanitize-html, dsb.) tidak dikirim. - Server Component tidak punya state, effect, event handler, browser API. - Server Component boleh async dan await langsung di render. - Client Component ('use client') tetap di-SSR lalu di-hydrate.

Alur request dengan Cache Components (Next 16.4):

request → static shell (prerendered, dari CDN/cache)
        → stream Suspense boundary (bagian dinamis)
        → client navigation: React <Activity> simpan state route lama

Poin yang sering salah dipahami: 'use client' bukan "render di client saja". Itu menandai boundary — modul itu dan semua import-nya ikut ke bundle client, dan tetap di-SSR sekali.


💻 Kode Nyata / Konfigurasi

1. Boundary dasar: server ambil data, client urus interaksi

// app/posts/[id]/page.tsx  — Server Component (default, tanpa directive)
import { db } from '@/lib/db';
import { LikeButton } from './like-button';

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;          // Next 15+: params adalah Promise
  const post = await db.post.findUnique({ where: { id } });

  if (!post) return <p>Not found</p>;

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {/* Hanya angka yang menyeberang boundary — bukan seluruh objek post */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}
// app/posts/[id]/like-button.tsx
'use client';
import { useState, useTransition } from 'react';
import { likePost } from './actions';

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => startTransition(async () => {
        setLikes((n) => n + 1);          // optimistic
        await likePost(postId);          // Server Action
      })}
    >
      ♥ {likes}
    </button>
  );
}

2. Serialisasi boundary — aturan yang bikin error

Yang boleh lewat server → client: string, number, boolean, null, plain object/array, Date, Map, Set, BigInt, Promise, JSX element, Server Action reference.

Yang tidak boleh: fungsi biasa, class instance, Symbol, dan apa pun yang tidak serializable.

// ❌ Error: Functions cannot be passed directly to Client Components
<Chart format={(n) => n.toFixed(2)} />

// ✅ Opsi A — jadikan Server Action
// actions.ts
'use server';
export async function formatCurrency(n: number) { return new Intl.NumberFormat('id-ID').format(n); }
<Chart formatAction={formatCurrency} />

// ✅ Opsi B — hitung di server, kirim hasilnya
<Chart labels={rows.map(r => formatCurrency(r.total))} values={rows.map(r => r.total)} />

Pola produksi: jangan kirim objek DB mentah ke client. Map ke DTO kecil di server. Ini bukan soal gaya — ini soal ukuran payload RSC di wire.

3. Caching eksplisit (Next.js 16.4 — Cache Components)

// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
export default nextConfig;
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';

export default async function DashboardPage() {
  const user = await getCurrentUser();          // request-time, tidak di-cache

  return (
    <div>
      <p>Halo, {user.name}</p>
      <Suspense fallback={<Skeleton />}>
        <Projects userId={user.id} />
      </Suspense>
    </div>
  );
}

async function Projects({ userId }: { userId: string }) {
  'use cache';
  cacheLife('hours');
  cacheTag(`projects-${userId}`);

  const rows = await db.project.findMany({ where: { userId } });
  return <ul>{rows.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Invalidasi setelah mutasi:

'use server';
import { updateTag, revalidateTag } from 'next/cache';

export async function renameProject(id: string, name: string) {
  await db.project.update({ where: { id }, data: { name } });
  updateTag(`projects-${userId}`);        // expire + refresh sekarang (read-your-writes)
}

// revalidateTag(tag, 'max' | 'hours' | 'days' | { expire: 3600 })
// → serve stale, revalidate di background (SWR)

Bedanya: updateTag = blokir sampai fresh (untuk mutasi user sendiri). revalidateTag = background (untuk konten yang boleh sedikit basi).

4. Prefetch selektif — jangan kirim seluruh halaman ke setiap link

// Next.js 16.4: navigation() mengeluarkan konten dari prefetch, tetap dirender saat navigasi nyata
import { navigation } from 'next/cache';

async function Message({ id }: { id: string }) {
  const msg = await getMessage(id);           // 'use cache' — ikut prefetch
  return (
    <>
      <p>{msg.subject}</p>
      <Suspense fallback={<Spinner />}>
        <Thread id={id} />                     {/* tidak ikut prefetch */}
      </Suspense>
    </>
  );
}

async function Thread({ id }: { id: string }) {
  await navigation();
  const thread = await getThread(id);
  return <>{thread.map((m) => <div key={m.id}>{m.body}</div>)}</>;
}

DIKETAHUI: tiap link dengan prefetch yang resolve URL data = satu server invocation. Di halaman dengan 50 link, itu 50 invocation. navigation() adalah cara resmi memotong biaya itu tanpa mematikan prefetch sepenuhnya.

5. Guard agar route tetap statis

// app/layout.tsx — gagal build kalau ada konten dinamis nyasar
export const ensureStatic = 'navigation';   // 'shell' | 'prefetch' | 'navigation'

Berguna untuk ecommerce/marketing/blog: satu komponen dinamis yang tidak sengaja masuk bisa menaikkan biaya compute seluruh route.


📊 Angka & Benchmark

Yang bisa saya verifikasi dari sumber resmi:

Angka Sumber
Library marked 35.9K (11.2K gz) + sanitize-html 206K (63.3K gz) = ~75K gz yang hilang dari bundle kalau render markdown di Server Component react.dev/reference/rsc/server-components
Turbopack disk cache Next 16.4: 20–25% lebih kecil (Zstandard untuk data, LZ4 untuk metadata) nextjs.org/blog/next-16-4
React Compiler (Rust, experimental): memory −30%, compile time −15% nextjs.org/blog/next-16-4
Cache Components jadi default create-next-app sejak 16.4; jadi default penuh di Next 17 nextjs.org/blog/next-16-4

BELUM PASTI: tidak ada benchmark publik netral (Lighthouse/WebPageTest, beberapa skenario) yang membandingkan RSC vs client-side rendering secara apples-to-apples. Klaim "RSC selalu lebih cepat" tidak bisa saya dukung dengan data. Yang bisa didukung: bundle JS lebih kecil dan tidak ada request waterfall data di client. Itu berbeda dari "LCP lebih cepat" — LCP tergantung TTFB server, yang justru bisa lebih lambat kalau server render jadi bottleneck.


⚖️ Trade-off & Alternatif

Aspek RSC (Next App Router) SSR klasik (Remix/Astro SSR) SPA + API (Vite/React)
Bundle JS client Kecil (server code tidak ikut) Sedang Besar
Data waterfall Hilang (fetch di server) Tergantung loader Ada, harus di-manage
Mental model Kompleks — dua lingkungan, aturan serialisasi Sedang Sederhana
Caching Eksplisit ('use cache') tapi banyak knob HTTP cache biasa Client cache (TanStack Query)
Biaya server Per request (kecuali di-cache) Per request Nyaris nol
Debugging Susah — stack trace lintas boundary Sedang Mudah
Lock-in Tinggi ke Next.js (API bundler non-semver) Sedang Rendah

Trade-off yang paling sering diabaikan: RSC memindahkan biaya dari browser user ke server kamu. Untuk app dengan trafik tinggi dan data yang sering berubah, ini bisa lebih mahal daripada SPA + CDN API. Cache Components ada persis untuk menutup celah ini — tapi caching yang salah = data basi, dan data basi di app finansial lebih mahal daripada bundle 200KB.

Alternatif yang layak dipertimbangkan: Astro (islands, zero-JS default untuk konten), TanStack Start (RSC-like tanpa lock-in Next), atau tetap SPA + TanStack Query kalau app-nya sangat interaktif dan data-heavy (dashboard, editor).


🏭 Use Case Produksi & Failure Mode Umum

Cocok: konten-heavy (blog, docs, ecommerce katalog), dashboard dengan data personal + shell statis, app yang butuh SEO dan first-load cepat.

Kurang cocok: app sangat interaktif (Figma-like, editor realtime), tool internal yang tidak butuh SEO dan timnya kecil — kompleksitas RSC tidak terbayar.

Failure mode yang benar-benar terjadi:

  1. 'use client' menyebar ke atas. Satu komponen di root layout diberi 'use client', seluruh subtree jadi client. Bundle meledak, RSC-nya sia-sia. Cek dengan next build output ukuran route.
  2. Objek besar menyeberang boundary. Kirim post utuh (termasuk body 50KB) ke client padahal cuma butuh id dan likes. Payload RSC membengkak, tidak terlihat di bundle analyzer karena bukan JS.
  3. Waterfall di dalam Server Component. await berurutan padahal independen: tsx const user = await getUser(); // 80ms const posts = await getPosts(); // 120ms → total 200ms // ✅ Promise.all → 120ms Di client dulu ini kelihatan sebagai spinner; di server ia jadi TTFB. Sama-sama lambat, cuma tidak kelihatan.
  4. State hilang saat navigasi (atau justru tidak hilang). Dengan cacheComponents: true, Next membungkus route dalam <Activity> — state komponen dipertahankan antar navigasi (maks 3 route). Ini breaking untuk form yang mengharapkan reset. Ada diskusi aktif di repo Next.js soal ini (issue #86577, discussion #89160). DIKETAHUI perilakunya; BELUM PASTI kapan jalur opt-out resmi mendarat.
  5. Cache stampede. 'use cache' dengan cacheLife('seconds') di route trafik tinggi → revalidasi bersamaan. Perlu single-flight atau stale-while-revalidate (pakai profil 'max'/'hours' kalau data tidak benar-benar butuh detik).
  6. Date.now() / Math.random() di dalam 'use cache'. Cache Components menolak ini — nilai berbeda tiap eksekusi. Pakai connection() untuk memaksa request-time, atau pindahkan ke luar cache scope.

🛠️ Latihan Praktis Kecil

Target: 15 menit, di laptop, tanpa deploy.

npx create-next-app@latest rsc-lab --ts --app --no-tailwind --no-eslint
cd rsc-lab
npm run dev
  1. Buat app/heavy/page.tsx: tsx import { marked } from 'marked'; // npm i marked export default async function Page() { const md = '# Halo\n\nIni **server** render.'; return <div dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }} />; }
  2. Buka DevTools → Network → cek bundle JS route /heavy. marked tidak ada di sana.
  3. Sekarang tambah 'use client' di baris pertama file itu, reload, cek lagi. marked muncul di bundle. Itu selisih yang dibayar user.
  4. Tambah console.log('render') di file. Lihat di terminal server (muncul) vs console browser (tidak muncul) → bukti dua lingkungan berbeda.
  5. Coba kirim fungsi sebagai prop ke komponen 'use client' → baca error persisnya. Hafalkan bunyinya, itu error paling sering muncul.

✅ Cek Pemahaman

  1. Kamu punya <ProductCard> yang menampilkan harga dari DB dan tombol "add to cart". Bagian mana yang server, mana yang client, dan data apa persisnya yang menyeberang boundary?
  2. Kenapa 'use client' di app/layout.tsx menghapus hampir semua manfaat RSC, padahal hanya satu directive?
  3. Kamu punya route dengan 30 link prefetch. Tiap prefetch = 1 server invocation. Bagaimana memotong biayanya tanpa mematikan prefetch — dan apa risiko UX-nya?

🔑 Poin Kunci


➡️ Eksplorasi Lanjut


Sumber: [1] React Team. "React 19.3" — react.dev/blog, 9 Sep 2026. https://react.dev/blog/2026/09/09/react-19-3 [2] React Team. "Server Components" — react.dev reference. https://react.dev/reference/rsc/server-components [3] React Team. "React Versions" — react.dev/versions (latest 19.3, patch 19.3.1 14 Okt 2026). https://react.dev/versions [4] Vercel. "Next.js 16.4" — nextjs.org/blog, 6 Okt 2026. https://nextjs.org/blog/next-16-4 [5] Vercel. "Next.js 16" — nextjs.org/blog. https://nextjs.org/blog/next-16 [6] Vercel. "Caching (Cache Components)" — nextjs.org/docs. https://nextjs.org/docs/app/getting-started/caching [7] vercel/next.js. "Multiple issues after migrating to Cache Components" — discussion #89160, 28 Jan 2026. https://github.com/vercel/next.js/discussions/89160

Limitations: Tidak ada benchmark pihak ketiga yang saya verifikasi untuk klaim performa RSC vs alternatif. Semua angka di atas berasal dari blog resmi React/Next.js — yaitu vendor yang menjual solusinya, jadi perlakukan sebagai klaim vendor, bukan pengukuran independen. Perilaku <Activity> saat ini masih bergerak; cek changelog Next.js sebelum mengandalkan workaround apa pun.