WebSocket di React terdengar mudah sampai kamu menghadapi masalah nyatanya: komponen unmount lalu mount lagi, koneksi yang seharusnya tetap hidup malah terputus dan buka lagi. Dua komponen berbeda subscribe ke URL yang sama, tapi masing-masing membuka koneksi sendiri. Belum lagi soal reconnect ketika jaringan putus sejenak.
Pendekatan hook sederhana berbasis useState dan useEffect cukup untuk demo, tapi rapuh di production. Arsitektur yang lebih kokoh memisahkan logika koneksi dari lifecycle React — dan pola class-based menjadi pilihan yang elegan untuk kebutuhan ini.
Artikel ini membahas cara merancang library WebSocket TypeScript dengan arsitektur class-based: satu koneksi per URL, auto-reconnect dengan exponential backoff, streaming data reaktif, dan message request/response berbasis Promise. Semua diintegrasikan ke React lewat custom hook yang tipis.
Kenapa Class-Based, Bukan Hook Biasa
Hook React dirancang untuk reaktivitas di dalam tree component. Masalahnya, koneksi WebSocket adalah resource yang seharusnya hidup di luar tree — dia tidak boleh ikut mati ketika sebuah komponen unmount.
Pola yang dipopulerkan TanStack Form cocok diterapkan di sini: logika inti hidup di dalam class, hook React hanya jadi wrapper tipis. Class instance bersifat referentially stable — dibuat sekali, dipakai ulang oleh banyak komponen. Reaktivitas ditangani TanStack Store, yang menggunakan useSyncExternalStore di baliknya untuk integrasi yang aman dengan concurrent rendering React.
Hasilnya: state tersinkronisasi tanpa prop drilling, tanpa Context Provider, dan tanpa risiko koneksi yang terbuka berkali-kali untuk URL yang sama.
Arsitektur: Tiga Kelas Utama
Sebelum menulis kode, tentukan dulu pembagian tanggung jawab:
| Kelas | Tanggung Jawab |
|---|---|
ConnectionManager | Daur hidup koneksi raw WebSocket, reconnect, heartbeat |
SubscriptionChannel | Streaming data berkelanjutan via TanStack Store |
MessageChannel | Request/response satu kali berbasis Promise |
Pembagian ini membuat setiap kelas fokus pada satu concern. ConnectionManager tidak tahu soal React; SubscriptionChannel tidak tahu soal jaringan mentah.
Membangun ConnectionManager
ConnectionManager adalah inti dari seluruh sistem. Dia membuka koneksi WebSocket, menjaga agar tetap hidup lewat heartbeat, dan meng-handle reconnect saat koneksi terputus.
// src/lib/websocket/ConnectionManager.ts
type MessageHandler = (uri: string, data: unknown) => void
const BACKOFF_DELAYS = [4_000, 30_000, 90_000] // ms
function getBackoffDelay(attempt: number): number {
if (attempt <= 4) return BACKOFF_DELAYS[0]
if (attempt <= 9) return BACKOFF_DELAYS[1]
return BACKOFF_DELAYS[2]
}
export class ConnectionManager {
private socket: WebSocket | null = null
private handlers = new Map<string, Set<MessageHandler>>()
private attempt = 0
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private pongTimeout: ReturnType<typeof setTimeout> | null = null
constructor(private readonly url: string) {}
connect(): void {
if (this.socket?.readyState === WebSocket.OPEN) return
this.socket = new WebSocket(this.url)
this.socket.onopen = () => this.onOpen()
this.socket.onmessage = (event) => this.onMessage(event)
this.socket.onclose = (event) => this.onClose(event)
}
private onOpen(): void {
this.attempt = 0
this.startHeartbeat()
}
private onMessage(event: MessageEvent): void {
const parsed = JSON.parse(event.data as string) as { uri: string; data: unknown }
if (parsed.uri === '__pong__') {
if (this.pongTimeout) clearTimeout(this.pongTimeout)
return
}
const listeners = this.handlers.get(parsed.uri)
listeners?.forEach((handler) => handler(parsed.uri, parsed.data))
}
private onClose(event: CloseEvent): void {
this.stopHeartbeat()
if (event.code === 1000) return // Normal Closure, tidak perlu reconnect
const extraDelay = event.code === 1013 ? 30_000 : 0
const delay = getBackoffDelay(this.attempt) + extraDelay
if (this.attempt >= 20) {
console.warn('[WS] Max reconnect attempts reached. Manual retry required.')
return
}
this.reconnectTimer = setTimeout(() => {
this.attempt++
this.connect()
}, delay)
}
private startHeartbeat(): void {
this.pingTimer = setInterval(() => {
this.socket?.send(JSON.stringify({ uri: '__ping__' }))
this.pongTimeout = setTimeout(() => {
this.socket?.close(1001, 'Pong timeout')
}, 10_000)
}, 40_000)
}
private stopHeartbeat(): void {
if (this.pingTimer) clearInterval(this.pingTimer)
if (this.pongTimeout) clearTimeout(this.pongTimeout)
}
addHandler(uri: string, handler: MessageHandler): void {
if (!this.handlers.has(uri)) {
this.handlers.set(uri, new Set())
}
this.handlers.get(uri)!.add(handler)
this.connect()
}
removeHandler(uri: string, handler: MessageHandler): void {
this.handlers.get(uri)?.delete(handler)
if (this.handlers.get(uri)?.size === 0) {
this.handlers.delete(uri)
}
if (this.handlers.size === 0) {
this.socket?.close(1000)
this.socket = null
}
}
send(payload: unknown): void {
this.socket?.send(JSON.stringify(payload))
}
}
Beberapa detail yang perlu diperhatikan: koneksi dibuka secara lazy — connect() baru dipanggil ketika handler pertama ditambahkan lewat addHandler. Saat semua handler dihapus, socket ditutup dengan kode 1000 (Normal Closure). Kode selain 1000 akan memicu reconnect, kecuali jika sudah melewati 20 percobaan.
SubscriptionChannel untuk Streaming Data
Kelas ini menangani streaming data berulang — notifikasi, live feed, atau daftar yang terus diperbarui. Reaktivitas-nya bersumber dari TanStack Store.
// src/lib/websocket/SubscriptionChannel.ts
import { Store } from '@tanstack/store'
import { ConnectionManager } from './ConnectionManager'
interface ChannelState<T> {
data: T | null
pending: boolean
}
export class SubscriptionChannel<T> {
readonly store: Store<ChannelState<T>>
private handler: ((uri: string, data: unknown) => void) | null = null
constructor(
private readonly connection: ConnectionManager,
private readonly uri: string,
private readonly subscribeBody?: unknown
) {
this.store = new Store<ChannelState<T>>({ data: null, pending: true })
}
subscribe(): void {
if (this.handler) return // Sudah subscribed
this.handler = (_uri: string, incoming: unknown) => {
this.store.setState((prev) => ({ ...prev, data: incoming as T, pending: false }))
}
this.connection.addHandler(this.uri, this.handler)
if (this.subscribeBody !== undefined) {
this.connection.send({ uri: this.uri, body: this.subscribeBody })
}
}
unsubscribe(): void {
if (!this.handler) return
this.connection.removeHandler(this.uri, this.handler)
this.handler = null
this.store.setState((prev) => ({ ...prev, pending: true }))
}
}
Store dari TanStack akan me-notify semua subscriber saat setState dipanggil — termasuk komponen React yang menggunakan useStore dari paket yang sama.
MessageChannel untuk Request/Response
Berbeda dengan SubscriptionChannel yang terus mendengarkan, MessageChannel hanya menunggu satu respons untuk setiap pesan yang dikirim.
// src/lib/websocket/MessageChannel.ts
import { ConnectionManager } from './ConnectionManager'
interface PendingRequest {
resolve: (data: unknown) => void
reject: (reason: string) => void
}
export class MessageChannel {
private pendingRequests = new Map<string, PendingRequest>()
constructor(private readonly connection: ConnectionManager) {}
sendMessage<R>(uri: string, body?: unknown): Promise<R> {
const existing = this.pendingRequests.get(uri)
if (existing) {
existing.reject('Request overwritten by newer request')
}
return new Promise<R>((resolve, reject) => {
this.pendingRequests.set(uri, {
resolve: (data) => {
this.pendingRequests.delete(uri)
resolve(data as R)
},
reject: (reason) => {
this.pendingRequests.delete(uri)
reject(new Error(reason))
},
})
const handler = (_responseUri: string, data: unknown) => {
const pending = this.pendingRequests.get(uri)
if (pending) pending.resolve(data)
this.connection.removeHandler(uri, handler)
}
this.connection.addHandler(uri, handler)
this.connection.send({ uri, body })
})
}
sendAndForget(uri: string, body?: unknown): void {
this.connection.send({ uri, body })
}
}
Kalau kamu mengirim pesan ke URI yang sama sebelum respons pertama datang, request lama akan di-reject dengan pesan yang jelas. Ini mencegah race condition diam-diam yang susah di-debug.
Registry: Singleton per URL
Agar dua komponen berbeda yang terhubung ke URL yang sama tidak membuka koneksi ganda, kita butuh registry yang menyimpan satu instance ConnectionManager per URL.
// src/lib/websocket/registry.ts
import { ConnectionManager } from './ConnectionManager'
const connections = new Map<string, ConnectionManager>()
export function getConnection(url: string): ConnectionManager {
if (!connections.has(url)) {
connections.set(url, new ConnectionManager(url))
}
return connections.get(url)!
}
Sederhana, tapi efeknya signifikan: satu koneksi per URL, berapapun komponen yang subscribe.
Custom Hook sebagai Wrapper React
Dengan semua logika tersentralisasi di class, hook-nya bisa dibuat sangat tipis.
// src/hooks/useTaskStream.ts
import { useEffect, useState } from 'react'
import { useStore } from '@tanstack/react-store'
import { SubscriptionChannel } from '@/lib/websocket/SubscriptionChannel'
import { getConnection } from '@/lib/websocket/registry'
interface Task {
id: string
title: string
status: 'pending' | 'in_progress' | 'done'
}
export function useTaskStream(projectId: string) {
const connection = getConnection('wss://api.example.com/ws')
const [channel] = useState(
() => new SubscriptionChannel<Task[]>(connection, '/projects/tasks', { projectId })
)
useEffect(() => {
channel.subscribe()
return () => {
const timer = setTimeout(() => channel.unsubscribe(), 3_000)
return () => clearTimeout(timer)
}
}, [channel])
const tasks = useStore(channel.store, (s) => s.data)
const isPending = useStore(channel.store, (s) => s.pending)
return { tasks, isPending }
}
Delay 3 detik sebelum unsubscribe mencegah koneksi terputus saat pengguna navigasi antar halaman yang sama-sama membutuhkan data tersebut. Jika komponen mount lagi dalam 3 detik, cleanup dibatalkan dan subscription tetap aktif.
Hook untuk satu kali pesan juga sama ringkasnya:
// src/hooks/useTaskAction.ts
import { useState } from 'react'
import { MessageChannel } from '@/lib/websocket/MessageChannel'
import { getConnection } from '@/lib/websocket/registry'
export function useTaskAction() {
const connection = getConnection('wss://api.example.com/ws')
const [channel] = useState(() => new MessageChannel(connection))
async function completeTask(taskId: string): Promise<{ success: boolean }> {
return channel.sendMessage('/tasks/complete', { taskId })
}
function archiveTask(taskId: string): void {
channel.sendAndForget('/tasks/archive', { taskId })
}
return { completeTask, archiveTask }
}
Menggunakan Hook di Komponen
Dengan dua hook di atas, kode komponen jadi bersih dari detail koneksi sama sekali.
// src/components/ProjectTaskList.tsx
import { useTaskStream } from '@/hooks/useTaskStream'
import { useTaskAction } from '@/hooks/useTaskAction'
interface Props {
projectId: string
}
export function ProjectTaskList({ projectId }: Props) {
const { tasks, isPending } = useTaskStream(projectId)
const { completeTask, archiveTask } = useTaskAction()
if (isPending) return <p>Memuat tugas...</p>
if (!tasks || tasks.length === 0) return <p>Belum ada tugas.</p>
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span>{task.title}</span>
<span> — {task.status}</span>
<button onClick={() => completeTask(task.id)}>Selesai</button>
<button onClick={() => archiveTask(task.id)}>Arsip</button>
</li>
))}
</ul>
)
}
Komponen tidak tahu sama sekali soal WebSocket, koneksi, atau reconnect. Semua itu ditangani di lapisan bawah.
Hal yang Perlu Diperhatikan
Jangan gunakan key yang sama di komponen berbeda dengan body yang berbeda. Karena instance SubscriptionChannel berbagi singleton per key, jika dua komponen membuat channel dengan uri sama tapi subscribeBody berbeda, salah satunya akan mendapat data yang tidak sesuai ekspektasi.
Registry singleton berbasis URL berarti semua komponen berbagi state yang sama. Pastikan kamu tidak menyimpan data sensitif di store yang bisa diakses komponen lain secara tidak sengaja.
Arsitektur ini juga bergantung pada format pesan yang konsisten dari server — setiap pesan harus memiliki field uri sebagai routing key. Jika server kamu menggunakan format berbeda, sesuaikan parsing di onMessage pada ConnectionManager.
Kesimpulan
Pola class-based untuk WebSocket di React bukan pilihan yang umum, tapi ia memecahkan masalah yang nyata: koneksi yang stabil lintas navigasi, berbagi state tanpa duplikasi, dan pemisahan yang jelas antara logika jaringan dengan logika tampilan. Dengan TanStack Store sebagai lapisan reaktivitas, integrasi ke React tetap idiomatik tanpa harus menyerahkan kendali lifecycle ke dalam hook. Untuk kebutuhan yang lebih kompleks seperti authentication handshake saat connect atau multiplexing pesan berdasarkan tenant, fondasi ini bisa dikembangkan lebih lanjut tanpa harus merombak arsitektur dasarnya.