Kenapa ini penting
Aplikasi yang terasa profesional punya tiga hal: kerangka yang konsisten (navbar tidak berkedip saat pindah halaman), umpan balik saat memuat (skeleton, bukan layar putih), dan penanganan error yang ramah (pesan jelas dan tombol "coba lagi", bukan layar crash). Di React biasa kamu harus merakit ketiganya sendiri. Next.js menyediakannya lewat file khusus: layout.js, template.js, loading.js, error.js, dan global-error.js.
Konsep inti
Urutan pembungkusan (hierarki komponen)
Di setiap folder rute, file-file khusus disusun seperti boneka matryoshka (dari luar ke dalam):
layout.js
└── template.js
└── error.js (error boundary React)
└── loading.js (Suspense boundary React)
└── not-found.js
└── page.js atau layout.js milik subfolder
Konsekuensi penting dari urutan ini:
error.jstidak menangkap error darilayout.js(dantemplate.js) di folder yang sama, karena layout ada di luarnya. Error di layout ditangkaperror.jsmilik folder induk, atauglobal-error.jsuntuk root layout.loading.jsmembungkuspage.js, jadi layout tetap tampil sementara halaman memuat.
Layout bersarang
Layout di folder induk membungkus layout di subfolder. Dengan app/layout.js dan app/blog/layout.js, halaman /blog/halo-nextjs dibungkus root layout lalu blog layout. Layout:
- Menerima prop
children(wajib dirender). - Tidak di-render ulang saat navigasi antar halaman di bawahnya. State di dalamnya (misalnya input pencarian di sidebar) tetap.
- Root layout wajib ada dan satu-satunya tempat
<html>dan<body>. - Bisa mengekspor
metadata(dibahas di modul SEO).
template.js: seperti layout tapi dibuat ulang
template.js mirip layout, tapi diberi key unik sehingga dipasang ulang (remount) setiap kali segmennya berganti. Akibatnya state Client Component di dalamnya di-reset, useEffect berjalan lagi, dan elemen DOM dibuat ulang. Gunakan untuk animasi masuk per halaman, atau form yang harus kosong lagi tiap pindah halaman. Perubahan query string saja tidak memicu remount. Kalau ragu, pakai layout; template untuk kebutuhan khusus.
loading.js dan Suspense
loading.js otomatis membungkus page.js dengan <Suspense> dari React. Saat halaman menunggu data, user langsung melihat UI loading, lalu konten asli menggantikannya. Analoginya seperti restoran yang langsung menyajikan air putih dan kerupuk begitu kamu duduk, sambil makanan utama dimasak.
Untuk kontrol lebih halus, pakai <Suspense> langsung di dalam halaman: bagian di luar Suspense tampil seketika, bagian di dalamnya menyusul. Ini disebut streaming. Dokumentasi Next.js menyarankan <Suspense> sedekat mungkin dengan data yang lambat, karena loading.js tidak bisa menutupi data lambat yang diakses di layout pada segmen yang sama.
error.js: error boundary per rute
error.js menangkap error tak terduga saat render di segmen tersebut dan menampilkan UI cadangan, sementara layout di atasnya tetap tampil dan bisa dipakai. Aturannya:
- Harus Client Component (
'use client'di baris pertama), karena error boundary React butuh state di browser. - Menerima prop
errordanretry.erroradalah nilai yang dilempar (dari Server Component berupa objekError); di produksi pesannya disamarkan demi keamanan, tapi adaerror.digestuntuk dicocokkan dengan log server. retry()(stabil sejak 16.3) mencoba mengambil ulang data dan merender ulang segmen. Ada jugareset()yang hanya mengosongkan state error tanpa mengambil ulang; dokumentasi menyarankanretry()untuk sebagian besar kasus. Tutorial lama hanya menyebutreset.- Error boundary tidak menangkap error di event handler (
onClick). Tangani itu dengantry/catchbiasa.
Untuk error yang diharapkan (validasi form gagal, data kosong), jangan throw. Tampilkan pesan biasa atau pakai notFound(). error.js untuk hal yang benar-benar tak terduga.
global-error.js
Menangkap error di root layout. Karena ia menggantikan root layout saat aktif, ia wajib punya <html> dan <body> sendiri. Letaknya di app/global-error.js.
Contoh kode
Loading untuk semua halaman di /blog:
// app/blog/loading.jsx
export default function Loading() {
return <p className="animate-pulse text-gray-500">Memuat artikel...</p>
}
// → saat membuka halaman blog yang datanya belum siap, teks ini tampil dulu
Error boundary untuk /blog:
// app/blog/error.jsx
'use client' // error boundary WAJIB Client Component
import { useEffect } from 'react'
export default function ErrorBlog({ error, retry }) {
useEffect(() => {
console.error(error) // di proyek nyata: kirim ke layanan monitoring
}, [error])
return (
<div className="rounded border border-red-300 bg-red-50 p-4">
<h2 className="font-bold">Waduh, artikel gagal dimuat.</h2>
<button onClick={() => retry()} className="mt-2 underline">
Coba lagi
</button>
</div>
)
}
// → navbar dari app/blog/layout.jsx tetap tampil di atas kotak merah ini
Template untuk animasi masuk:
// app/blog/template.jsx
export default function BlogTemplate({ children }) {
// dipasang ulang setiap pindah artikel → animasi berjalan lagi
return <div className="animate-[fadeIn_0.3s]">{children}</div>
}
Error global:
// app/global-error.jsx
'use client'
export default function GlobalError({ error, retry }) {
return (
<html lang="id">
<body>
<h2>Aplikasi mengalami masalah serius.</h2>
<button onClick={() => retry()}>Muat ulang</button>
</body>
</html>
)
}
Streaming granular dengan beberapa <Suspense>. Bagian yang cepat tidak menunggu bagian yang lambat:
// app/dashboard/page.jsx
import { Suspense } from 'react'
import { connection } from 'next/server'
const tunggu = (ms) => new Promise((r) => setTimeout(r, ms))
async function Ringkasan() {
await connection() // tandai: bagian ini dirender saat request, bukan saat build
await tunggu(800)
return <p>Halo, Sari! Kamu punya 2 tugas.</p>
}
async function Notifikasi() {
await connection()
await tunggu(2000) // sengaja lambat
return <p>🔔 3 notifikasi baru</p>
}
export default function DashboardPage() {
return (
<main className="space-y-4 p-6">
<h1 className="text-2xl font-bold">Dashboard</h1> {/* langsung tampil */}
<Suspense fallback={<p>Memuat ringkasan...</p>}>
<Ringkasan /> {/* muncul setelah ±0,8 detik */}
</Suspense>
<Suspense fallback={<p>Memuat notifikasi...</p>}>
<Notifikasi /> {/* muncul setelah ±2 detik, tidak menahan Ringkasan */}
</Suspense>
</main>
)
}
Mencoba error boundary: tambahkan sementara baris ini di app/blog/page.jsx, buka /blog, lalu hapus lagi.
// app/blog/page.jsx (sementara, untuk uji coba)
export default async function BlogPage() {
throw new Error('Database sedang down') // → UI dari error.jsx tampil
}
Kesalahan umum
- Lupa
'use client'dierror.js. Next.js menolak karena error boundary harus Client Component. - Mengira
error.jsmenangkap error dilayout.jsyang sama. Tidak. Pindahkan logika berisiko kepage.js, atau tangani dierror.jsfolder induk/global-error.js. global-error.jstanpa<html>dan<body>. Wajib, karena ia menggantikan root layout.- Memakai
throwuntuk error yang wajar. Form tidak valid atau artikel tidak ada bukan "crash". Tampilkan pesan atau pakainotFound(). - Satu
loading.jsbesar untuk semuanya. Seluruh halaman jadi menunggu. Lebih baik beberapa<Suspense>di dekat bagian yang lambat.
Latihan
- Mudah, Buat
app/produk/loading.jsxberisi tiga kotak abu-abu (skeleton) memakai Tailwindanimate-pulse. - Sedang, Buat
app/produk/error.jsxyang menampilkan pesan error (kalauerroradalahError, tampilkanerror.message) dan tombol "Coba lagi" memanggilretry(). Uji dengan melempar error dipage.jsx. - Menantang, Di sebuah halaman, buat dua komponen async:
Cuaca(tunggu 1 detik) danBerita(tunggu 3 detik), masing-masing diawaliawait connection(). Tampilkan keduanya dengan<Suspense>terpisah, lalu jelaskan urutan tampilnya.
Lihat solusi
1.
// app/produk/loading.jsx
export default function Loading() {
return (
<div className="space-y-3 p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 animate-pulse rounded bg-gray-200" />
))}
</div>
)
}
2.
// app/produk/error.jsx
'use client'
export default function ErrorProduk({ error, retry }) {
const pesan = error instanceof Error ? error.message : 'Terjadi kesalahan'
return (
<div className="p-6">
<p className="text-red-600">Gagal memuat produk: {pesan}</p>
<button onClick={() => retry()} className="mt-2 rounded border px-3 py-1">
Coba lagi
</button>
</div>
)
}
// → di mode dev pesan aslinya tampil; di produksi pesan dari Server Component disamarkan
3.
// app/info/page.jsx
import { Suspense } from 'react'
import { connection } from 'next/server'
const tunggu = (ms) => new Promise((r) => setTimeout(r, ms))
async function Cuaca() {
await connection()
await tunggu(1000)
return <p>☀️ Jakarta 32°C</p>
}
async function Berita() {
await connection()
await tunggu(3000)
return <p>📰 Harga kopi naik 5%</p>
}
export default function InfoPage() {
return (
<main className="p-6">
<h1>Info Hari Ini</h1>
<Suspense fallback={<p>Memuat cuaca...</p>}><Cuaca /></Suspense>
<Suspense fallback={<p>Memuat berita...</p>}><Berita /></Suspense>
</main>
)
}
Urutannya: judul dan dua teks "Memuat..." tampil seketika, cuaca muncul setelah ±1 detik, berita setelah ±3 detik. Keduanya berjalan bersamaan, jadi total tunggu ±3 detik, bukan 4.
Ringkasan
- Urutan pembungkus: layout → template → error → loading → not-found → page.
- Layout dipakai bersama dan tidak di-render ulang saat navigasi; template dipasang ulang setiap segmen berganti.
loading.jsmembungkus halaman dengan Suspense;<Suspense>langsung memberi streaming yang lebih granular.error.jswajib'use client', menerimaerrordanretry, dan tidak menangkap error layout di segmen yang sama.global-error.jsmenangani error root layout dan wajib punya<html>dan<body>.