Server yang Menulis Judul, Klien yang Mengisi Daftar
Cangkang halaman, metadata, dan judul datang dari server; daftar, tab, dan pencarian debounce 350 ms hidup di satu island klien.
Ringkasan
Halaman informasi olahraga baru dibuat. Server hanya menulis metadata dan judul, isi daftarnya diambil di browser. Judul dan metadata dihasilkan di server untuk mesin telusur.
Halaman /informasi/olahraga baru selesai dibuat, lalu saya buka: judul "Informasi Olahraga — Cabang Olahraga & Kelembagaan" sudah ada, enam kotak abu-abu bergetar di bawahnya, dan setelah itu kartu organisasi satu per satu terisi. Judul datang lebih dulu karena server yang menulisnya; isi daftarnya menyusul dari browser. Commit 368d0e2.
Tebakan awal saya, halaman ini dirender penuh di server seperti halaman lain di proyek itu: daftar, nama pengurus, semuanya keluar dalam satu HTML. Yang terjadi di kode berbeda. Server hanya menulis metadata, judul, dan kerangka fallback; baris data pertama pun tidak pernah lewat dari server, semua diambil di browser pada saat komponen mount.
Cangkang di Server, Isi di Klien
Di page.tsx ada export const dynamic = "force-dynamic", lalu generateMetadata() yang mengisi judul dan deskripsi halaman. Next.js merespons metadata itu menjadi tag <head> halaman [4], jadi bagian yang paling berharga untuk mesin telusur tetap dihasilkan di server tanpa satu baris JavaScript klien pun. Judul <h1> juga ikut di server, begitu pula fallback enam kotak yang dibungkus <Suspense>:
export const dynamic = "force-dynamic";
export default function InformasiOlahragaPage() {
return (
<Container className="py-8">
<h1 className="mb-6 text-2xl font-bold">Informasi Olahraga</h1>
<Suspense
fallback={
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
))}
</div>
}
>
<CaborList />
</Suspense>
</Container>
);
}
Baris default di App Router memang begitu: layout dan halaman adalah Server Component, dan Client Component dipakai saat halaman butuh interaksi atau API browser [1]. Di proyek ini yang butuh interaksi hanya satu bagian, jadi yang dikirim ke klien cuma <CaborList />.
Satu Island, Enam State, Satu Timer
cabor-list.tsx dibuka dengan "use client" dan langsung memegang seluruh state halaman: tab aktif, teks pencarian, daftar organisasi, total, status loading, dan organisasi yang sedang dibuka. Rekam jejak yang berubah seiring waktu seperti ini disebut state [2], dan state hanya bisa hidup di Client Component.
"use client";
const [activeTab, setActiveTab] = useState(0);
const [search, setSearch] = useState("");
const [orgs, setOrgs] = useState<Organization[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
useEffect(() => {
const timer = setTimeout(fetchData, 350);
return () => clearTimeout(timer);
}, [fetchData]);
Perhatikan enam baris terakhir. Pencarian tidak langsung memicu request: fetchData dijadwalkan 350 milidetik, dan clearTimeout di fungsi cleanup membatalkan jadwal lama tiap kali tab atau teks pencarian berubah. React menjalankan cleanup dengan nilai lama sebelum setup berikutnya jalan [3], jadi yang sampai ke server hanya permintaan terakhir. Tanpa cleanup itu, lima huruf yang diketik cepat akan mengirim lima request dan urutan jawabannya bisa saling menyalip.
Effect yang dependensinya selalu berubah memang akan jalan ulang setiap kali komponen commit [3]; di sini itu justru yang saya mau, karena perubahan tab dan pencarian memang pemicu ambil data yang sah. Sisanya urusan tampilan: skeleton saat loading, pesan "Tidak ada data ditemukan." saat kosong, aria-label pada kolom pencarian, dan data-testid pada tab, kolom cari, serta hitungan hasil untuk pengujian end-to-end.
Pemanggilan datanya sendiri lewat satu pembungkus, listOrganizations, yang mengikuti konvensi endpoints.ts yang sudah dipakai modul lain, dan tipenya diambil dari v1.d.ts hasil generate dari spesifikasi API. Artinya perubahan kontrak tidak diketik ulang di halaman ini: kalau field di spesifikasi berubah, tipe ikut berubah dan impor di sini yang protes saat build.
Kartu yang diklik tidak membuka rute baru. selectedOrg diisi, CaborList berhenti mengembalikan daftar dan mengembalikan <CaborDetail /> berisi tabel pengurus, lalu tombol kembali mengosongkan state itu lagi. Satu island, dua tampilan, tanpa navigasi; pilihan itu masuk akal untuk daftar yang isinya cuma perlu dibuka dan ditutup di layar yang sama.
Pertanyaan yang berguna saat membagi halaman semacam ini bukan "server atau klien", tapi setengah mana yang tetap harus terbaca kalau JavaScript mati: judul, metadata, dan pesan kosong, bukan isi daftarnya.
Sumber: