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
function Dashboard() {
  const [user, setUser] = useState(null)
  const [orders, setOrders] = useState(null)
  const [stats, setStats] = useState(null)
  const [userLoading, setUserLoading] = useState(true)
  const [ordersLoading, setOrdersLoading] = useState(true)
  const [statsLoading, setStatsLoading] = useState(true)

  useEffect(() => {
    fetchUser().then(data => {
      setUser(data)
      setUserLoading(false)
    })
    fetchOrders().then(data => {
      setOrders(data)
      setOrdersLoading(false)
    })
    fetchStats().then(data => {
      setStats(data)
      setStatsLoading(false)
    })
  }, [])

  return (
    <div>
      {userLoading ? <UserSkeleton /> : <UserProfile user={user} />}
      {ordersLoading ? <OrdersSkeleton /> : <RecentOrders orders={orders} />}
      {statsLoading ? <StatsSkeleton /> : <StatsCards stats={stats} />}
    </div>
  )
}
// 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
function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />       {/* Fetch data-nya sendiri */}
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />      {/* Fetch data-nya sendiri */}
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsCards />        {/* Fetch data-nya sendiri */}
      </Suspense>
    </div>
  )
}
// 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:#fff

React.lazy — Suspense untuk Code Splitting #

import { lazy, Suspense } from 'react'

// Komponen dimuat secara lazy — hanya saat dibutuhkan
const HeavyChart = lazy(() => import('./HeavyChart'))
const DataTable = lazy(() => import('./DataTable'))
const RichEditor = lazy(() => import('./RichEditor'))

function ReportPage() {
  return (
    <div>
      <ReportHeader />   {/* Tidak lazy — dimuat segera */}

      <Suspense fallback={<ChartSkeleton height={300} />}>
        <HeavyChart data={reportData} />
      </Suspense>

      <Suspense fallback={<TableSkeleton rows={10} />}>
        <DataTable />
      </Suspense>
    </div>
  )
}

// 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) #

// React Query mendukung Suspense natively
import { useSuspenseQuery } from '@tanstack/react-query'

// Komponen ini "suspend" saat data belum tersedia
function UserProfile({ userId }) {
  // Tidak ada loading state — komponen suspend otomatis
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  // Kode ini hanya dieksekusi saat user sudah tersedia
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}

// Parent tidak perlu tahu tentang loading state UserProfile
function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={userId} />
    </Suspense>
  )
}

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
function Dashboard() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      <UserProfile />    {/* ready dalam 200ms */}
      <RecentOrders />   {/* ready dalam 800ms */}
      <HeavyAnalytics /> {/* ready dalam 3000ms! */}
    </Suspense>
  )
}
// UserProfile dan RecentOrders harus menunggu HeavyAnalytics (3 detik!)
// Seluruh halaman skeleton sampai HeavyAnalytics selesai

// BENAR: Boundary per section → setiap bagian muncul segera saat siap
function Dashboard() {
  return (
    <div>
      {/* Konten yang hampir selalu cepat — boundary di atas bersama */}
      <Suspense fallback={<HeaderSkeleton />}>
        <DashboardHeader />
      </Suspense>

      <div className="grid">
        {/* Konten yang kecepatannya independen — boundary terpisah */}
        <Suspense fallback={<UserSkeleton />}>
          <UserProfile />
        </Suspense>

        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>

      {/* Konten berat — boundary sendiri di bagian bawah */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <HeavyAnalytics />
      </Suspense>
    </div>
  )
}
// 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.

import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

// Komponen untuk menampilkan error dengan opsi retry
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error-state">
      <p>Gagal memuat data: {error.message}</p>
      <button onClick={resetErrorBoundary}>Coba Lagi</button>
    </div>
  )
}

// Kombinasi ErrorBoundary + Suspense untuk satu section
function OrdersSection() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
    </ErrorBoundary>
  )
}

// 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.

import { useTransition, Suspense } from 'react'

function ProductFilter() {
  const [category, setCategory] = useState('all')
  const [isPending, startTransition] = useTransition()

  function handleCategoryChange(newCategory) {
    startTransition(() => {
      // Update state ini di-mark sebagai "non-urgent"
      // React akan tetap tampilkan konten lama sampai update selesai
      // tanpa menampilkan skeleton/fallback
      setCategory(newCategory)
    })
  }

  return (
    <div>
      <CategoryButtons
        current={category}
        onChange={handleCategoryChange}
        // isPending: tampilkan indikator loading ringan di button
        // tanpa menggantikan seluruh content dengan skeleton
        disabled={isPending}
      />

      {isPending && <div className="loading-bar" />}  {/* Progress bar tipis */}

      <Suspense fallback={<ProductSkeleton />}>
        <ProductList category={category} />
      </Suspense>
    </div>
  )
}
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.

// app/dashboard/page.tsx — Next.js App Router
import { Suspense } from 'react'

// Server Component — fetch data langsung di komponen, tanpa useEffect
async function UserProfile({ userId }) {
  const user = await db.users.findById(userId)  // langsung query DB di server
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
    </div>
  )
}

async function RecentOrders({ userId }) {
  const orders = await db.orders.findByUser(userId)  // bisa lebih lambat
  return <OrdersList orders={orders} />
}

async function Analytics() {
  const stats = await externalAnalyticsApi.getStats()  // bisa sangat lambat
  return <StatsCards stats={stats} />
}

// Page adalah server component yang compose komponen-komponen di atas
export default function DashboardPage({ params }) {
  return (
    <main>
      {/* UserProfile render di server, di-stream ke client saat ready */}
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile userId={params.id} />
      </Suspense>

      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders userId={params.id} />
      </Suspense>

      {/* Analytics bisa lambat — letakkan di bawah dengan boundary terpisah */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
    </main>
  )
}

// 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
<Suspense fallback={<div>Loading...</div>}>
  <ProductList />
</Suspense>
// User tidak tahu struktur apa yang akan muncul
// Layout shift parah saat konten muncul (CLS tinggi)

// BENAR: Skeleton yang menyerupai konten asli
function ProductListSkeleton() {
  return (
    <div className="product-grid">
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="product-card-skeleton">
          <div className="skeleton skeleton-image" style={{ height: 200 }} />
          <div className="skeleton skeleton-text" style={{ width: '80%' }} />
          <div className="skeleton skeleton-text" style={{ width: '60%' }} />
          <div className="skeleton skeleton-price" style={{ width: '40%' }} />
        </div>
      ))}
    </div>
  )
}

<Suspense fallback={<ProductListSkeleton />}>
  <ProductList />
</Suspense>
// 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
function App() {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Router>
    </Suspense>
  )
}
// Jika satu komponen kecil di Dashboard suspend → seluruh app jadi spinner!

// ✓ Solusi: Suspense boundary di level yang tepat
function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={
          <Suspense fallback={<HomeSkeleton />}><Home /></Suspense>
        } />
        <Route path="/dashboard" element={
          <Suspense fallback={<DashboardSkeleton />}><Dashboard /></Suspense>
        } />
      </Routes>
    </Router>
  )
}

Suspense Tanpa Error Boundary #

// ✗ Anti-pattern: Suspense tanpa Error Boundary
<Suspense fallback={<Skeleton />}>
  <DataComponent />  {/* Jika fetch gagal → error tidak tertangkap! */}
</Suspense>
// Error dari DataComponent akan propagate ke atas dan crash

// ✓ Solusi: Selalu pasangkan dengan Error Boundary
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Skeleton />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

Fallback yang Menyebabkan Layout Shift #

// ✗ Anti-pattern: fallback dengan dimensi yang salah
<Suspense fallback={<div style={{ height: 50 }}>Loading...</div>}>
  <ProductList />  {/* ProductList sebenarnya 800px tingginya */}
</Suspense>
// Saat konten muncul: layout shift dari 50px ke 800px = CLS tinggi!

// ✓ Solusi: Skeleton dengan dimensi yang mendekati konten asli
<Suspense fallback={<ProductListSkeleton />}>  {/* ~800px, 8 card */}
  <ProductList />
</Suspense>

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.

← Sebelumnya: PWA   Berikutnya: WEBP →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact