Suspense #
Suspense adalah pola UI yang memungkinkan aplikasi menampilkan fallback sementara — skeleton, spinner, atau placeholder — sambil menunggu sesuatu yang async: lazy-loaded komponen, data yang di-fetch, atau konten yang di-stream dari server. Yang membuat Suspense powerful bukan teknologinya, melainkan paradigmanya: logika loading state tidak lagi tersebar di setiap komponen dengan if (isLoading) return <Spinner />, melainkan dipusatkan di boundary yang lebih tinggi. Ini membuat rendering code lebih bersih dan memberikan kontrol yang lebih baik atas urutan munculnya konten. Artikel ini membahas Suspense dari konsep dasar, cara kerja di React dan framework lain, streaming HTML dari server, anti-pattern, dan kapan Suspense memberikan manfaat yang nyata.
Masalah yang Dipecahkan Suspense #
Tanpa Suspense, penanganan loading state tersebar dan sering tidak konsisten:
// ANTI-PATTERN: Loading state manual di setiap komponen
func Dashboard() *Element {
user, setUser := useState(nil)
orders, setOrders := useState(nil)
stats, setStats := useState(nil)
userLoading, setUserLoading := useState(true)
ordersLoading, setOrdersLoading := useState(true)
statsLoading, setStatsLoading := useState(true)
useEffect(func() {
fetchUser().then(func(data any) {
setUser(data)
setUserLoading(false)
})
fetchOrders().then(func(data any) {
setOrders(data)
setOrdersLoading(false)
})
fetchStats().then(func(data any) {
setStats(data)
setStatsLoading(false)
})
})
return renderFragment(
renderConditional(userLoading, renderUserSkeleton(), renderUserProfile(user)),
renderConditional(ordersLoading, renderOrdersSkeleton(), renderRecentOrders(orders)),
renderConditional(statsLoading, renderStatsSkeleton(), renderStatsCards(stats)),
)
}
// Masalah:
// → Boilerplate loading state di setiap komponen
// → Tidak konsisten — setiap developer bisa implement berbeda
// → Sulit mengkoordinasikan urutan munculnya konten
// → Komponen data fetching dan loading UI tercampur
Dengan Suspense, loading state dikelola oleh boundary, bukan oleh masing-masing komponen:
// BENAR: Suspense boundary mengelola loading state
func Dashboard() *Element {
return renderFragment(
renderSuspense(renderUserSkeleton(), func() *Element {
return renderUserProfile() // Fetch data-nya sendiri
}),
renderSuspense(renderOrdersSkeleton(), func() *Element {
return renderRecentOrders() // Fetch data-nya sendiri
}),
renderSuspense(renderStatsSkeleton(), func() *Element {
return renderStatsCards() // Fetch data-nya sendiri
}),
)
}
// Keuntungan:
// → Setiap section loading secara independen
// → Komponen fokus pada rendering, bukan loading management
// → Loading state konsisten dan terpusat
Cara Kerja React Suspense #
flowchart TD
subgraph WithoutSuspense["Tanpa Suspense — Sequential Loading"]
W1["Fetch semua data\n(harus tunggu semua selesai)"]
W2["Render seluruh halaman\nsaat semua data ready"]
WU["User menunggu\n2-3 detik blank / spinner"]
W1 --> W2 --> WU
end
subgraph WithSuspense["Dengan Suspense — Progressive Rendering"]
S1["Mulai fetch semua data\nsecara parallel"]
S2["Bagian statis render segera"]
S3["UserProfile ready (200ms)\n→ Muncul, gantikan skeleton"]
S4["StatsCards ready (500ms)\n→ Muncul, gantikan skeleton"]
S5["RecentOrders ready (800ms)\n→ Muncul, gantikan skeleton"]
SU["User melihat konten\nmuncul bertahap"]
S1 --> S2 --> SU
S1 --> S3 --> SU
S1 --> S4 --> SU
S1 --> S5 --> SU
end
style WU fill:#E74C3C,color:#fff
style SU fill:#27AE60,color:#fffReact.lazy — Suspense untuk Code Splitting #
// React.lazy — Suspense untuk Code Splitting
// (React adalah framework API — ini menunjukkan logika lazy-loading yang setara)
var HeavyChart = lazyLoad(func() *Element { return loadComponent("HeavyChart") })
var DataTable = lazyLoad(func() *Element { return loadComponent("DataTable") })
var RichEditor = lazyLoad(func() *Element { return loadComponent("RichEditor") })
func ReportPage() *Element {
return renderFragment(
renderReportHeader(), // Tidak lazy — dimuat segera
renderSuspense(renderChartSkeleton(300), func() *Element {
return renderHeavyChart(reportData)
}),
renderSuspense(renderTableSkeleton(10), func() *Element {
return renderDataTable()
}),
)
}
// React otomatis:
// 1. Mulai download HeavyChart.chunk.js
// 2. Tampilkan <ChartSkeleton /> selama download
// 3. Saat download selesai, gantikan skeleton dengan komponen asli
// 4. Tidak ada if (loading) sama sekali di komponen parent
Suspense dengan Data Fetching (React 18 + React Query) #
// Suspense dengan Data Fetching (React 18 + React Query)
// (React adalah framework API — ini menunjukkan logika suspense yang setara)
func UserProfile(userId string) *Element {
// Tidak ada loading state — komponen suspend otomatis
user := useSuspenseQuery("user", userId, func() any { return fetchUser(userId) })
// Kode ini hanya dieksekusi saat user sudah tersedia
return renderFragment(
renderImage(user.Avatar, user.Name),
renderHeading(user.Name),
renderParagraph(user.Email),
)
}
// Parent tidak perlu tahu tentang loading state UserProfile
func ProfilePage(userId string) *Element {
return renderSuspense(renderProfileSkeleton(), func() *Element {
return renderUserProfile(userId)
})
}
Granularity Suspense Boundary #
Posisi dan granularity Suspense boundary sangat mempengaruhi UX. Boundary yang terlalu kasar membuat banyak konten menunggu konten yang lambat; boundary yang terlalu granular bisa terasa berantakan.
// ANTI-PATTERN: Satu boundary untuk semua → konten cepat menunggu konten lambat
func Dashboard() *Element {
return renderSuspense(renderFullPageSkeleton(), func() *Element {
return renderFragment(
renderUserProfile(), // ready dalam 200ms
renderRecentOrders(), // ready dalam 800ms
renderHeavyAnalytics(), // ready dalam 3000ms!
)
})
}
// UserProfile dan RecentOrders harus menunggu HeavyAnalytics (3 detik!)
// Seluruh halaman skeleton sampai HeavyAnalytics selesai
// BENAR: Boundary per section → setiap bagian muncul segera saat siap
func Dashboard() *Element {
return renderFragment(
// Konten yang hampir selalu cepat — boundary di atas bersama
renderSuspense(renderHeaderSkeleton(), func() *Element { return renderDashboardHeader() }),
// Konten yang kecepatannya independen — boundary terpisah
renderSuspense(renderUserSkeleton(), func() *Element { return renderUserProfile() }),
renderSuspense(renderOrdersSkeleton(), func() *Element { return renderRecentOrders() }),
// Konten berat — boundary sendiri di bagian bawah
renderSuspense(renderAnalyticsSkeleton(), func() *Element { return renderHeavyAnalytics() }),
)
}
// UserProfile muncul di 200ms, RecentOrders di 800ms, HeavyAnalytics di 3000ms
// User sudah bisa lihat dan interact dengan sebagian konten jauh lebih awal
Suspense dan Error Boundary #
Suspense menangani loading state. Error Boundary menangani error state. Keduanya sering digunakan bersama.
// Suspense dan Error Boundary
// (React adalah framework API — ini menunjukkan logika penanganan error yang setara)
// Komponen untuk menampilkan error dengan opsi retry
func ErrorFallback(error Error, resetErrorBoundary func()) *Element {
return renderFragment(
renderParagraph("Gagal memuat data: "+error.Message),
renderButton("Coba Lagi", resetErrorBoundary),
)
}
// Kombinasi ErrorBoundary + Suspense untuk satu section
func OrdersSection() *Element {
return renderErrorBoundary(
func() *Element { return renderErrorFallback(error, resetErrorBoundary) },
renderSuspense(renderOrdersSkeleton(), func() *Element { return renderRecentOrders() }),
)
}
// Order yang benar: ErrorBoundary di luar Suspense
// Jika ErrorBoundary di dalam Suspense:
// → Error dari RecentOrders tidak akan tertangkap ErrorBoundary
// karena Suspense meng-intercept lebih dulu
useTransition — Suspense untuk Navigasi #
useTransition adalah hook React 18 yang memungkinkan update state yang bisa “di-interrupt” tanpa menampilkan fallback Suspense secara langsung — sangat berguna untuk transisi navigasi yang mulus.
// useTransition — Suspense untuk Navigasi
// (React adalah framework API — ini menunjukkan logika transisi yang setara)
func ProductFilter() *Element {
category, setCategory := useState("all")
isPending, startTransition := useTransition()
handleCategoryChange := func(newCategory string) {
startTransition(func() {
// Update state ini di-mark sebagai "non-urgent"
// React akan tetap tampilkan konten lama sampai update selesai
// tanpa menampilkan skeleton/fallback
setCategory(newCategory)
})
}
return renderFragment(
renderCategoryButtons(
category,
handleCategoryChange,
isPending, // tampilkan indikator loading ringan di button
// tanpa menggantikan seluruh content dengan skeleton
),
renderConditional(isPending, renderLoadingBar(), nil), // Progress bar tipis
renderSuspense(renderProductSkeleton(), func() *Element {
return renderProductList(category)
}),
)
}
Perbedaan useTransition vs tanpa useTransition:
Tanpa useTransition:
User klik filter → skeleton muncul seketika → data baru muncul
Konten lama hilang, user kehilangan context
Dengan useTransition:
User klik filter → konten lama tetap terlihat (sedikit dimmed)
→ progress bar tipis muncul
→ konten baru muncul, progress bar hilang
User tetap bisa lihat struktur halaman selama loading
Streaming HTML — Suspense di Sisi Server #
Konsep Suspense tidak terbatas pada React. Server bisa menerapkan pola yang sama: kirim konten statis terlebih dahulu via streaming HTTP, lalu kirim konten yang membutuhkan waktu lebih lama saat sudah siap.
sequenceDiagram
participant B as Browser
participant S as Server Go
B->>S: GET /dashboard
S-->>B: HTML: Header + Nav (langsung)
Note over B: Browser render header segera
S-->>B: HTML: Skeleton placeholder untuk products
Note over B: Browser tampilkan skeleton products
Note over S: Query database products (200ms)
S-->>B: HTML: Script inject konten products
Note over B: Browser gantikan skeleton dengan data nyata
Note over S: Ambil rekomendasi dari ML service (1500ms)
S-->>B: HTML: Script inject konten rekomendasi
Note over B: Rekomendasi muncul
S-->>B: HTML: Footer (langsung)
Note over B: Halaman selesai// Backend Go — Streaming HTML dengan placeholder + inject
package main
import (
"fmt"
"net/http"
"time"
)
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
// Transfer-Encoding: chunked otomatis saat menggunakan Flusher
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// === LANGKAH 1: Kirim shell halaman segera ===
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<nav>Dashboard Nav</nav>
<main>
<h1>Dashboard</h1>
<!-- Placeholder untuk products — tampil segera sebagai skeleton -->
<div id="products-container">
<div class="skeleton skeleton-list"></div>
</div>
<!-- Placeholder untuk rekomendasi -->
<div id="reco-container">
<div class="skeleton skeleton-grid"></div>
</div>
</main>
<footer>Footer</footer>
<script src="/app.js"></script>
`)
flusher.Flush() // Kirim segera ke browser
// === LANGKAH 2: Fetch data products (simulasi 200ms) ===
time.Sleep(200 * time.Millisecond)
products := fetchProducts() // actual DB query
// Inject konten ke placeholder via script
fmt.Fprintf(w, `<script>
document.getElementById('products-container').innerHTML = %q;
</script>`, renderProductsHTML(products))
flusher.Flush()
// === LANGKAH 3: Fetch rekomendasi (simulasi 1.5s) ===
time.Sleep(1300 * time.Millisecond) // total 1.5s
recommendations := fetchRecommendations()
fmt.Fprintf(w, `<script>
document.getElementById('reco-container').innerHTML = %q;
</script>`, renderRecoHTML(recommendations))
flusher.Flush()
// Tutup koneksi
fmt.Fprint(w, `</body></html>`)
}
Pendekatan streaming HTML server ini adalah cara paling sederhana untuk menerapkan Suspense-style loading tanpa React atau framework frontend apapun — cukup Go/Node.js/Python dengan chunked response. Halaman tetap terasa cepat karena shell sudah terlihat, sementara bagian yang lambat menyusul. Ini adalah teknik yang sudah digunakan oleh Facebook sebelum React ada, dan masih sangat relevan untuk server-rendered application.
React Server Components + Suspense (Next.js App Router) #
React Server Components (RSC) memungkinkan komponen server di-render dan di-stream secara bertahap — ini adalah cara modern untuk menggunakan Suspense dengan data fetching di server.
// React Server Components + Suspense (Next.js App Router)
// (React adalah framework API — ini menunjukkan logika setara dalam Go)
// Server Component — fetch data langsung di komponen
func UserProfile(userId string) *Element {
user := dbFindUserByID(userId) // langsung query DB di server
return renderFragment(
renderImage(user.Avatar, user.Name),
renderHeading(user.Name),
)
}
func RecentOrders(userId string) *Element {
orders := dbFindOrdersByUser(userId) // bisa lebih lambat
return renderOrdersList(orders)
}
func Analytics() *Element {
stats := fetchExternalAnalytics() // bisa sangat lambat
return renderStatsCards(stats)
}
// Page adalah server component yang compose komponen-komponen di atas
func DashboardPage(params Params) *Element {
return renderFragment(
// UserProfile render di server, di-stream ke client saat ready
renderSuspense(renderProfileSkeleton(), func() *Element {
return renderUserProfile(params.ID)
}),
renderSuspense(renderOrdersSkeleton(), func() *Element {
return renderRecentOrders(params.ID)
}),
// Analytics bisa lambat — letakkan di bawah dengan boundary terpisah
renderSuspense(renderAnalyticsSkeleton(), func() *Element {
return renderAnalytics()
}),
)
}
// Next.js / React otomatis:
// 1. Render shell halaman segera
// 2. Stream setiap section saat data-nya siap di server
// 3. Browser menerima dan menampilkan konten secara bertahap
// → Tidak ada waterfall request dari client ke API
// → TTFB tetap rendah karena shell dikirim segera
Skeleton Screen — Fallback yang Baik #
Fallback Suspense yang baik adalah skeleton screen yang menyerupai layout konten asli, bukan spinner generik.
// ANTI-PATTERN: Spinner generik tidak informatif
renderSuspense(renderDiv("Loading..."), func() *Element { return renderProductList() })
// User tidak tahu struktur apa yang akan muncul
// Layout shift parah saat konten muncul (CLS tinggi)
// BENAR: Skeleton yang menyerupai konten asli
func ProductListSkeleton() *Element {
return renderDivClass("product-grid",
renderCards(8, func(i int) *Element {
return renderDivClass("product-card-skeleton",
renderSkeleton("skeleton-image", 200),
renderSkeletonText("80%"),
renderSkeletonText("60%"),
renderSkeletonText("40%"),
)
}),
)
}
renderSuspense(renderProductListSkeleton(), func() *Element { return renderProductList() })
// Keuntungan:
// → User tahu akan ada 8 kartu produk
// → Tidak ada layout shift saat konten muncul (CLS rendah)
// → Terasa "loading", bukan "broken"
Anti-Pattern Suspense yang Harus Dihindari #
Satu Boundary untuk Seluruh App #
// ✗ Anti-pattern: satu boundary global
func App() *Element {
return renderSuspense(renderFullPageSpinner(), func() *Element {
return renderRouter(
renderRoute("/", renderHome()),
renderRoute("/dashboard", renderDashboard()),
)
})
}
// Jika satu komponen kecil di Dashboard suspend → seluruh app jadi spinner!
// ✓ Solusi: Suspense boundary di level yang tepat
func App() *Element {
return renderRouter(
renderRoute("/", func() *Element {
return renderSuspense(renderHomeSkeleton(), func() *Element { return renderHome() })
}),
renderRoute("/dashboard", func() *Element {
return renderSuspense(renderDashboardSkeleton(), func() *Element { return renderDashboard() })
}),
)
}
Suspense Tanpa Error Boundary #
// ✗ Anti-pattern: Suspense tanpa Error Boundary
renderSuspense(renderSkeleton(), func() *Element {
return renderDataComponent() // Jika fetch gagal → error tidak tertangkap!
})
// Error dari DataComponent akan propagate ke atas dan crash
// ✓ Solusi: Selalu pasangkan dengan Error Boundary
renderErrorBoundary(
func() *Element { return renderErrorFallback() },
renderSuspense(renderSkeleton(), func() *Element { return renderDataComponent() }),
)
Fallback yang Menyebabkan Layout Shift #
// ✗ Anti-pattern: fallback dengan dimensi yang salah
renderSuspense(renderDivStyle("height: 50px", "Loading..."), func() *Element {
return renderProductList() // ProductList sebenarnya 800px tingginya
})
// Saat konten muncul: layout shift dari 50px ke 800px = CLS tinggi!
// ✓ Solusi: Skeleton dengan dimensi yang mendekati konten asli
renderSuspense(renderProductListSkeleton(), func() *Element { return renderProductList() }) // ~800px, 8 card
Kapan Suspense Memberikan Manfaat Nyata #
Suspense sangat bermanfaat ketika:
✓ Halaman terdiri dari beberapa section independen dengan kecepatan fetch berbeda
✓ Ada komponen berat yang bisa di-lazy load (chart library, rich editor, map)
✓ SSR dengan streaming (Next.js App Router, React Server Components)
✓ Navigasi antar halaman yang butuh loading indication yang mulus
Suspense kurang bermanfaat ketika:
✗ Komponen sederhana dengan satu data fetch tunggal
→ Manual loading state masih lebih straightforward
✗ Data yang harus ada semua sebelum apapun bisa ditampilkan
→ Single loading boundary lebih tepat
✗ Server rendering tanpa streaming support
→ Suspense boundary di SSR tanpa streaming = blocking render
Frameworks yang mendukung Suspense:
React: Suspense + React.lazy + React 18 concurrent features
Next.js: App Router dengan Server Components + streaming
Vue: <Suspense> component (experimental tapi stabil)
Nuxt: useLazyFetch + <NuxtLazyHydration>
Solid.js: <Suspense> built-in dengan resource API
Checklist Suspense #
BOUNDARY PLACEMENT:
□ Boundary per section independen, bukan satu global boundary
□ Komponen yang bisa lambat punya boundary sendiri
□ Navigasi level (route changes) punya boundary per route
□ Error Boundary selalu ada di luar setiap Suspense boundary
FALLBACK QUALITY:
□ Skeleton screen menyerupai layout konten asli (bukan spinner generic)
□ Dimensi skeleton mendekati dimensi konten asli (mencegah CLS)
□ Skeleton tidak terlalu detail — cukup untuk communicate struktur
□ Warna skeleton konsisten dengan design system
PERFORMA:
□ React.lazy digunakan untuk komponen yang tidak dibutuhkan di initial load
□ Data fetch parallel (semua dimulai bersamaan, bukan sequential)
□ useTransition untuk filter/sort/navigation yang ingin non-blocking
□ Suspense boundary tidak terlalu granular (tidak per komponen kecil)
SERVER STREAMING:
□ Shell halaman (header, nav, footer) dikirim segera tanpa menunggu data
□ Section yang lambat di-stream terpisah dengan placeholder
□ Error handling ada untuk kasus streaming gagal di tengah jalan
□ Transfer-Encoding: chunked dikonfigurasi dengan benar
TESTING:
□ Skeleton screen ditest di kondisi network lambat (Network Throttling di DevTools)
□ Error state ditest (matikan API, lihat Error Boundary bekerja)
□ CLS dicheck (tidak ada layout shift saat konten muncul)
Ringkasan #
- Suspense memindahkan tanggung jawab loading state dari komponen ke boundary — komponen fokus pada rendering data yang sudah ada, boundary yang mengelola apa yang ditampilkan saat data belum ada.
- Granularity boundary menentukan kualitas UX — boundary yang terlalu kasar membuat konten cepat menunggu yang lambat; terlalu granular membuat banyak skeleton muncul bersamaan. Temukan keseimbangan yang tepat per halaman.
- Selalu pasangkan Suspense dengan Error Boundary — Suspense menangani loading state, Error Boundary menangani error state. Keduanya saling melengkapi dan sama-sama diperlukan.
- Skeleton screen harus menyerupai konten asli — dimensi yang mendekati konten sebenarnya mencegah CLS (Cumulative Layout Shift) dan memberikan user ekspektasi yang akurat tentang apa yang akan muncul.
- Streaming HTML dari server adalah Suspense tanpa framework — chunked HTTP response dengan placeholder dan script injection memberikan pengalaman yang sama dengan React Suspense, tanpa memerlukan framework apapun.
- useTransition untuk navigasi non-blocking — alih-alih menampilkan skeleton saat filter berubah, konten lama tetap terlihat dengan progress indicator ringan. UX jauh lebih mulus.
- React Server Components + Suspense adalah kombinasi terkuat — fetch data langsung di server component, stream ke client saat ready, tanpa API call waterfall dari client.
- Jangan Suspense untuk setiap komponen kecil — overhead Suspense boundary ada. Gunakan untuk komponen yang memang bisa lambat atau berat, bukan sebagai default untuk semua komponen.
- Data fetch harus parallel, bukan sequential — Suspense boundary yang terpisah memungkinkan setiap section memulai fetch-nya bersamaan. Ini adalah salah satu keuntungan terbesar dari granularity boundary yang tepat.
- Suspense bukan pengganti loading state manual — untuk use case sederhana dengan satu data fetch dan satu komponen, manual loading state dengan
if (isLoading)masih lebih mudah dibaca dan di-maintain.