SPA #

Single Page Application adalah arsitektur di mana seluruh aplikasi berjalan dalam satu HTML document — navigasi antar “halaman” tidak memuat ulang dari server, melainkan JavaScript yang mengganti konten di dalam DOM. Ini memberikan pengalaman yang menyerupai aplikasi native: transisi instan, tidak ada flash of blank screen, state yang terpelihara saat pindah halaman. Tapi SPA punya tantangan unik yang tidak ada di MPA (Multi-Page Application) tradisional: manajemen state yang kompleks, memory leak yang tidak terdeteksi, masalah browser history, accessibility, dan initial load yang berat. Artikel ini membahas cara kerja SPA secara mendalam, tantangan spesifik yang perlu ditangani, dan best practice untuk membangun SPA yang robust.

SPA vs MPA — Perbedaan Fundamental #

Untuk memahami mengapa SPA membutuhkan pendekatan berbeda, perlu dipahami perbedaan fundamentalnya dengan aplikasi web tradisional.

flowchart LR
    subgraph MPA["MPA — Multi-Page Application"]
        MUser["User klik link"]
        MReq["Browser kirim request ke server"]
        MServer["Server generate HTML baru"]
        MRender["Browser render halaman baru\n(state lama hilang, scroll reset)"]
        MUser --> MReq --> MServer --> MRender
    end

    subgraph SPA["SPA — Single Page Application"]
        SUser["User klik link"]
        SRouter["Client-side router intercept"]
        SDOM["JavaScript update DOM\n(state terpelihara, tidak ada flash)"]
        SFetch["Fetch data dari API jika perlu"]
        SUser --> SRouter --> SDOM
        SRouter --> SFetch --> SDOM
    end
Perbedaan yang paling terasa oleh user:

MPA:
  Klik link → blank/putih sebentar → halaman baru muncul
  State hilang (form yang diisi, posisi scroll, tab yang aktif)
  Setiap halaman adalah "fresh start"

SPA:
  Klik link → konten berganti instan (tidak ada blank)
  State terpelihara (sidebar tetap expanded, musik tetap main)
  Navigasi terasa seperti aplikasi native

Perbedaan yang paling terasa oleh developer:

MPA:
  State sederhana — setiap request adalah "clean slate"
  Tidak perlu manage history API
  Browser handle back/forward secara natural
  Memory tidak menumpuk antar halaman

SPA:
  State kompleks — data dari halaman A masih ada saat di halaman B
  Harus manage History API secara manual
  Memory bisa leak jika event listener tidak di-cleanup
  Perlu state management library untuk aplikasi besar

Client-Side Routing — Jantung SPA #

Routing adalah mekanisme yang membuat SPA bisa “berpura-pura” punya banyak halaman. Ketika user mengklik link, router mencegat event itu, memperbarui URL menggunakan History API, dan merender komponen yang sesuai — semua tanpa request ke server.

// Cara kerja History API yang menjadi fondasi semua SPA router
// Ketika user klik link internal:
document.addEventListener('click', (e) => {
  const link = e.target.closest('a')
  if (!link || !isInternalLink(link.href)) return
  
  e.preventDefault()  // cegah browser reload halaman
  
  const url = new URL(link.href)
  
  // Update URL di address bar tanpa reload
  window.history.pushState(
    { path: url.pathname },  // state object
    '',                       // title (diabaikan browser modern)
    url.pathname              // URL baru
  )
  
  // Render komponen yang sesuai untuk path ini
  renderRoute(url.pathname)
})

// Handle back/forward button
window.addEventListener('popstate', (event) => {
  renderRoute(window.location.pathname)
})
Dua mode routing di SPA:

1. Hash-based routing: example.com/#/products/123
   → Bagian setelah # tidak pernah dikirim ke server
   → Tidak butuh konfigurasi server
   → URL kurang bersih, tidak di-index dengan baik oleh search engine
   → Cocok untuk: prototype, internal tools, aplikasi tanpa SEO requirement

2. History API routing: example.com/products/123
   → URL bersih, di-index oleh search engine
   → Server harus dikonfigurasi: semua path di-serve index.html
   → Jika tidak dikonfigurasi: refresh di /products/123 = 404!
   → Cocok untuk: production app, terutama yang butuh SEO atau sharable URL

// Konfigurasi Nginx untuk History API routing:
location / {
  try_files $uri $uri/ /index.html;
  // Jika file tidak ada, serve index.html — biarkan SPA yang handle routing
}

Masalah Deep Linking dan 404 pada Refresh #

Ini adalah salah satu “gotcha” paling umum saat pertama kali deploy SPA. User membuka app.com/dashboard/reports langsung (bukan dari navigasi SPA), dan mendapat 404.

Mengapa terjadi:

User navigate ke: app.com/dashboard/reports
Browser kirim request: GET /dashboard/reports ke server
Server mencari file /dashboard/reports — tidak ada!
Server return: 404 Not Found

Ini terjadi karena:
→ Di SPA, /dashboard/reports hanya ada di router JavaScript
→ Server tidak tahu tentang route ini
→ Server hanya punya satu file: index.html

Solusi per platform:

Nginx:
  location / {
    try_files $uri $uri/ /index.html;
  }

Apache (.htaccess):
  Options -MultiViews
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteRule ^ index.html [QSA,L]

Vercel (vercel.json):
  {
    "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
  }

Netlify (_redirects):
  /*  /index.html  200

AWS CloudFront:
  Custom error response: 404 → /index.html dengan HTTP 200
Jangan lupa konfigurasi server rewrite sebelum deploy SPA ke production. Ini adalah masalah yang hampir pasti terjadi jika tidak dikonfigurasi, dan akan membuat seluruh navigasi langsung (dari email, bookmark, atau share link) menghasilkan 404. Ini juga menyebabkan search engine tidak bisa crawl halaman-halaman SPA yang dalam.

State Management — Tantangan Terbesar SPA #

Di MPA, setiap halaman adalah fresh start — tidak ada state yang perlu dikoordinasikan antar halaman. Di SPA, semua halaman hidup dalam satu instance aplikasi dan bisa berbagi state, yang bisa menjadi kompleks dengan cepat.

flowchart TD
    subgraph Local["State Lokal — useState, ref"]
        L["Cocok untuk:\nForm input sementara\nToggle UI (open/close)\nAnimasi lokal\nData yang tidak perlu dishare"]
    end

    subgraph Lifted["Lifted State — Props / Context"]
        LI["Cocok untuk:\nData yang dishare 2-3 komponen\nTheme, language preference\nAuth state\nModal/drawer state"]
    end

    subgraph Server["Server State — React Query / SWR"]
        S["Cocok untuk:\nData dari API\nCache yang perlu invalidasi\nBackground refetch\nOptimistic updates"]
    end

    subgraph Global["Global State — Redux / Zustand / Pinia"]
        G["Cocok untuk:\nData kompleks yang dishare global\nUndo/redo\nMulti-step wizard state\nReal-time collaboration state"]
    end

    Q1{"Apakah state perlu\ndishare antar komponen?"}
    Q2{"Apakah data dari API?"}
    Q3{"Seberapa luas\njangkauan sharing?"}

    Q1 -->|"Tidak"| Local
    Q1 -->|"Ya"| Q2
    Q2 -->|"Ya"| Server
    Q2 -->|"Tidak"| Q3
    Q3 -->|"Beberapa komponen"| Lifted
    Q3 -->|"Seluruh aplikasi"| Global
// Kesalahan umum: semua state di-put ke global store
// ANTI-PATTERN
store.dispatch(setIsModalOpen(true))    // UI state yang cukup lokal
store.dispatch(setFormInput('email', value))  // form state yang sangat lokal
store.dispatch(setHoveredItemId(id))    // hover state yang sangat lokal

// Global store harus untuk data yang benar-benar global
// BENAR: Pisahkan berdasarkan jangkauan
function ProductModal({ product }) {
  const [isOpen, setIsOpen] = useState(false)     // lokal → useState
  const [formData, setFormData] = useState({})    // lokal → useState

  const { data: cart } = useCart()                // server state → React Query
  const { user } = useAuthStore()                 // global → Zustand/Redux
  // ...
}

Memory Leak — Masalah yang Tidak Terlihat #

Di MPA, memory dibebaskan setiap kali halaman baru dimuat. Di SPA, semua berjalan dalam satu browser session — memory leak terakumulasi dan bisa membuat aplikasi terasa makin lambat seiring waktu.

// Sumber memory leak yang paling umum di SPA:

// 1. Event listener yang tidak di-cleanup
// ANTI-PATTERN
function SearchComponent() {
  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown)  // terdaftar
    // Tidak ada cleanup! Event listener tetap aktif bahkan setelah komponen unmount
  }, [])
}

// BENAR: Selalu cleanup event listener
function SearchComponent() {
  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown)
    return () => {
      window.removeEventListener('keydown', handleKeyDown)  // cleanup saat unmount
    }
  }, [])
}

// 2. Timer dan interval yang tidak di-cleanup
// ANTI-PATTERN
function LiveClock() {
  const [time, setTime] = useState(new Date())
  useEffect(() => {
    setInterval(() => setTime(new Date()), 1000)  // interval berjalan selamanya!
  }, [])
}

// BENAR
function LiveClock() {
  const [time, setTime] = useState(new Date())
  useEffect(() => {
    const intervalId = setInterval(() => setTime(new Date()), 1000)
    return () => clearInterval(intervalId)  // cleanup saat unmount
  }, [])
}

// 3. Subscription yang tidak di-cleanup
// ANTI-PATTERN
function NotificationBell() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const unsubscribe = notificationService.subscribe(setCount)
    // Lupa panggil unsubscribe!
  }, [])
}

// BENAR
function NotificationBell() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const unsubscribe = notificationService.subscribe(setCount)
    return () => unsubscribe()  // cleanup
  }, [])
}

// 4. Async operation yang update state setelah unmount
// ANTI-PATTERN
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  useEffect(() => {
    fetchUser(userId).then(setUser)  // jika komponen unmount sebelum fetch selesai
    // → Error: "Can't update state on unmounted component"
  }, [userId])
}

// BENAR: AbortController untuk cancel fetch
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  useEffect(() => {
    const controller = new AbortController()
    fetchUser(userId, { signal: controller.signal })
      .then(setUser)
      .catch(err => {
        if (err.name !== 'AbortError') throw err
      })
    return () => controller.abort()  // cancel fetch saat unmount
  }, [userId])
}

Scroll Management dan Navigasi #

Di MPA, browser otomatis scroll ke atas setiap navigasi halaman. Di SPA, scroll harus dikelola secara manual.

// Masalah scroll yang umum di SPA:

// 1. Scroll tidak reset saat navigasi halaman baru
// User di /products scroll ke bawah → navigate ke /about → masih di posisi bawah!

// Solusi dengan React Router:
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'

function ScrollToTop() {
  const { pathname } = useLocation()
  
  useEffect(() => {
    window.scrollTo(0, 0)
  }, [pathname])
  
  return null
}

// Tambahkan di root router:
<Router>
  <ScrollToTop />
  <Routes>...</Routes>
</Router>

// 2. Scroll position tidak tersimpan saat back navigation
// User scroll di /products → klik produk → tekan back → scroll kembali ke atas
// Ini adalah behavior yang salah — seharusnya kembali ke posisi yang sama

// Solusi: simpan dan restore scroll position
const scrollPositions = {}

// Simpan saat leave
window.addEventListener('popstate', () => {
  scrollPositions[window.location.pathname] = window.scrollY
})

// Restore saat arrive
function restoreScroll(pathname) {
  const savedPosition = scrollPositions[pathname]
  if (savedPosition !== undefined) {
    window.scrollTo(0, savedPosition)
  }
}

Accessibility di SPA #

Accessibility (a11y) di SPA membutuhkan perhatian lebih karena screen reader dan assistive technology dirancang untuk model navigasi MPA.

Masalah accessibility yang spesifik untuk SPA:

1. Perpindahan fokus saat navigasi halaman
   Di MPA: fokus otomatis ke awal halaman saat load halaman baru
   Di SPA: fokus tetap di link yang diklik → screen reader tidak tahu konten berubah

   Solusi:
   // Saat route berubah, pindahkan fokus ke heading utama halaman
   function PageTransition() {
     const { pathname } = useLocation()
     const headingRef = useRef(null)
     
     useEffect(() => {
       headingRef.current?.focus()  // pindahkan fokus ke heading
     }, [pathname])
     
     return <h1 ref={headingRef} tabIndex="-1">...</h1>
     // tabIndex="-1" agar bisa di-focus via JS tapi tidak masuk tab order
   }

2. Page title tidak diupdate
   Di MPA: <title> berubah otomatis setiap halaman
   Di SPA: <title> harus diupdate secara manual saat navigasi
   
   // React Helmet atau Next.js Head component:
   <title>{currentPageTitle} | Nama Aplikasi</title>

3. Announcements untuk screen reader
   Screen reader perlu diberi tahu bahwa konten halaman sudah berubah
   
   // Live region untuk announcements
   <div aria-live="polite" aria-atomic="true">
     {navigationAnnouncement}
   </div>
   
   // Saat navigasi:
   setNavigationAnnouncement(`Halaman ${pageTitle} sudah dimuat`)

Optimasi Initial Load SPA #

Initial load adalah kelemahan terbesar SPA. Beberapa strategi untuk mengurangi waktu ini:

1. Bundle splitting yang agresif
   Pisahkan vendor (React, dll) dari app code
   → Vendor jarang berubah → cache lebih lama di browser
   
   // vite.config.js
   build: {
     rollupOptions: {
       output: {
         manualChunks: {
           vendor: ['react', 'react-dom'],
           router: ['react-router-dom'],
           charts: ['recharts'],  // library besar di-split terpisah
         }
       }
     }
   }

2. Preload critical assets
   Beritahu browser untuk mulai download sebelum HTML selesai di-parse
   
   <link rel="preload" href="/main.chunk.js" as="script">
   <link rel="preload" href="/app.css" as="style">

3. Prefetch route yang mungkin dikunjungi berikutnya
   Saat user hover link, mulai download chunk untuk route itu
   
   // React Router future visits optimization
   <Link
     to="/dashboard"
     onMouseEnter={() => {
       // Prefetch dashboard chunk
       import('./pages/Dashboard')
     }}
   >
     Dashboard
   </Link>

4. App Shell Pattern
   Load shell aplikasi (navbar, sidebar) segera
   Data konten dimuat setelahnya
   → User melihat struktur aplikasi segera, konten menyusul
   
   <div id="app">
     <Navbar />     {/* Dimuat dari bundle lokal, instan */}
     <Sidebar />    {/* Dimuat dari bundle lokal, instan */}
     <main>
       <Suspense fallback={<ContentSkeleton />}>
         <PageContent />  {/* Dimuat via lazy + fetch */}
       </Suspense>
     </main>
   </div>

Anti-Pattern SPA yang Harus Dihindari #

Menyimpan Semua State di URL Hash Params #

// ✗ Anti-pattern: filter dan pagination di hash
// example.com/#page=3&filter=electronics&sort=price_asc&view=grid
// URL hash tidak di-index search engine dan tidak bisa di-share dengan baik

// ✓ Solusi: Gunakan query string untuk state yang perlu di-share
// example.com/products?page=3&filter=electronics&sort=price_asc&view=grid

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const page = parseInt(searchParams.get('page') || '1')
  const filter = searchParams.get('filter') || ''
  
  // Update URL saat filter berubah
  const handleFilterChange = (newFilter) => {
    setSearchParams({ ...Object.fromEntries(searchParams), filter: newFilter })
  }
}

Tidak Menangani Loading dan Error State #

// ✗ Anti-pattern: tidak ada feedback saat loading atau error
function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetchUsers().then(setUsers)
    // Tidak ada loading state, tidak ada error handling
    // User melihat daftar kosong dan tidak tahu kenapa
  }, [])
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>
}

// ✓ Solusi: Handle semua state dengan UI yang bermakna
function UserList() {
  const { data: users, isLoading, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  
  if (isLoading) return <UserListSkeleton />
  if (error) return (
    <ErrorState
      message="Gagal memuat daftar user"
      onRetry={refetch}
    />
  )
  if (!users?.length) return <EmptyState message="Belum ada user" />
  
  return <ul>{users.map(u => <li>{u.name}</li>)}</ul>
}

Page Title yang Tidak Diupdate #

// ✗ Anti-pattern: judul halaman tidak berubah saat navigasi
// User di tab lain melihat "Aplikasi" tanpa tahu halaman mana yang aktif

// ✓ Solusi: Update document.title setiap route change
function ProductDetailPage({ product }) {
  useEffect(() => {
    document.title = `${product.name} | Toko Online`
    return () => {
      document.title = 'Toko Online'  // reset saat leave
    }
  }, [product.name])
  
  return <ProductDetail product={product} />
}

Kapan SPA Tepat dan Kapan Tidak #

SPA sangat tepat untuk:
  ✓ Aplikasi yang sangat interaktif dengan navigasi yang sering
     (Trello, Figma, Google Docs, admin panel yang kompleks)
  ✓ Aplikasi yang membutuhkan state terpelihara antar "halaman"
     (musik yang terus main, chat yang tidak terputus)
  ✓ Aplikasi yang menyerupai software desktop
     (design tools, spreadsheet, code editor)
  ✓ Dashboard dan internal tools yang tidak butuh SEO

SPA kurang tepat untuk:
  ✗ Website yang kontennya lebih banyak di-baca dari di-interaksikan
     (blog, dokumentasi, landing page)
  ✗ E-commerce yang butuh SEO kuat untuk product pages
  ✗ Aplikasi yang target user punya perangkat low-end atau koneksi lambat
  ✗ Tim kecil yang belum familiar dengan kompleksitas state management SPA

Pertimbangkan Meta-framework (Next.js, Nuxt, SvelteKit) jika:
  ✓ Butuh kombinasi: beberapa halaman SSR/SSG (untuk SEO) + beberapa halaman SPA (untuk interaktivitas)
  ✓ Ingin SPA experience tapi dengan initial load yang lebih cepat
  ✓ Tim butuh kemudahan routing yang sudah terstruktur

Checklist SPA #

ROUTING:
  □ Server dikonfigurasi untuk serve index.html untuk semua path (rewrite rules)
  □ Scroll position di-reset saat navigasi halaman baru
  □ Scroll position di-restore saat back navigation
  □ Deep linking berfungsi (refresh di /any/path tidak menghasilkan 404)

PERFORMANCE:
  □ Route-based code splitting diimplementasikan
  □ Vendor chunk dipisahkan dari app code
  □ Critical assets di-preload
  □ Bundle size dipantau per deployment

STATE MANAGEMENT:
  □ State lokal menggunakan useState/ref (tidak semua di global store)
  □ Server state menggunakan React Query/SWR
  □ Global store hanya untuk state yang benar-benar global
  □ State yang perlu di-share via URL menggunakan query params

MEMORY:
  □ Event listener di-cleanup di useEffect return
  □ Timer dan interval di-cleanup di useEffect return
  □ Subscription service di-cleanup di useEffect return
  □ Fetch operations di-cancel menggunakan AbortController saat unmount

ACCESSIBILITY:
  □ Fokus dipindahkan ke heading utama saat navigasi halaman
  □ Page title (document.title) diupdate setiap route change
  □ ARIA live region ada untuk announce perubahan konten ke screen reader
  □ Keyboard navigation berfungsi tanpa mouse

ERROR HANDLING:
  □ Loading state ada untuk setiap data fetch
  □ Error state ada dengan opsi retry
  □ Empty state ada untuk daftar yang kosong
  □ Global error boundary menangkap unexpected JavaScript error

SEO (jika ada konten publik):
  □ Meta tags diupdate per route
  □ Canonical URL dikonfigurasi
  □ Pre-rendering atau SSR hybrid untuk halaman yang butuh SEO

Ringkasan #

  • SPA tidak sama dengan CSR — SPA adalah arsitektur (satu HTML document, client-side routing), CSR adalah teknik rendering. SPA hampir selalu menggunakan CSR, tapi bisa juga dikombinasikan dengan SSR (seperti Next.js App Router).
  • Server rewrite rules wajib untuk History API routing — tanpa ini, refresh di URL apa pun selain root akan menghasilkan 404. Konfigurasi ini sering terlupakan saat pertama deploy.
  • Memory leak terakumulasi di SPA — selalu cleanup event listener, timer, subscription, dan async operation di useEffect return function. Memory leak yang tidak ditangani membuat aplikasi makin lambat seiring waktu.
  • State management harus sesuai jangkauan — gunakan local state untuk state yang lokal, lifted state untuk yang dishare beberapa komponen, React Query/SWR untuk server state, dan global store hanya untuk yang benar-benar global.
  • Scroll management harus dilakukan manual — browser tidak auto-scroll ke atas saat navigasi SPA. Gunakan scroll-to-top saat route berubah, tapi restore position saat back navigation.
  • Accessibility membutuhkan perhatian ekstra — screen reader tidak mendapat sinyal otomatis bahwa konten halaman berubah. Pindahkan fokus ke heading utama, update page title, dan gunakan ARIA live regions.
  • Query string untuk state yang perlu di-share — filter, pagination, dan sort preference yang ingin bisa di-bookmark atau di-share harus ada di URL sebagai query string, bukan hanya di memory.
  • App Shell Pattern mengurangi perceived loading time — render shell (navbar, sidebar) dari bundle lokal secara instan, biarkan konten menyusul via lazy loading.
  • Prefetch untuk route yang kemungkinan dikunjungi — saat user hover link, mulai download chunk untuk route tersebut agar transisi terasa instan.
  • Meta-framework jika butuh fleksibilitas lebih — Next.js, Nuxt, SvelteKit memberikan SPA experience untuk halaman-halaman interaktif tapi SSR/SSG untuk halaman yang butuh SEO, dalam satu aplikasi yang terintegrasi.

← Sebelumnya: SSR   Berikutnya: PWA →

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