Mengelola State Dengan Observer Pattern (Typescript)

Oct 22, 2022 22:17 · 953 words · 5 minute read #design pattern #typescript

Mengelola State Dengan Observer Pattern (Typescript)
Image by Couleur from Pixabay

Tujuan dari penulisan artikel ini adalah untuk mendemonstrasikan bahwa untuk me-manage state secara reaktif itu tidaklah sulit. Kita tak perlu harus selalu bergantung kepada third party library seperti Rxjs, Redux, Zustand, atau malah React. Saya percaya bahwa dengan memahami Observer Pattern saja sudah cukup untuk mengimplementasikan state yang reaktif.

Studi Kasus

Oke langsung masuk ke studi kasus: anggap saja kita sedang membuat aplikasi video player, dimana selain user bisa memainkan video, ia juga bisa mengganti tema player. Jadi state kita ada dua: isPlaying, dan theme.

TypeScript
function playerUI() {
  type Theme = 'light' | 'dark'
  type State = {
    isPlaying: boolean
    theme: Theme
  }

  const state: State = {
    isPlaying: false,
    theme: 'light',
  }

  function togglePlay() {
    state.isPlaying = !state.isPlaying
  }

  function toggleTheme() {
    state.theme = state.theme === 'light' ? 'dark' : 'light'
  }

  return {
    state,
    togglePlay,
    toggleTheme,
  }
}

Looks good. Dari sisi consumer pun cukup jelas.

TypeScript
const ui = playerUI()

console.log(ui.state.isPlaying) // false
ui.togglePlay()
console.log(ui.state.isPlaying) // true

Wow emejing. Tapi kemudian requirement berubah: tim UX memutuskan untuk mengubah behavior dimana ketika player sedang dijalankan (isPlaying == true) elemen-elemen UI lain harus disembunyikan untuk memaksimalkan user experience.

TypeScript
function onPlayback() {
  ui.togglePlay()
  const { isPlaying } = ui.state

  if (isPlaying) {
    navbar.hide()
    sidebar.hide()
  } else {
    navbar.show()
    sidebar.show()
  }
}

<button onclick="onPlayback">▶⏸</button>

Kalau dilihat baik-baik, fungsi onPlayback kini mengatur semua side effect dari perubahan state isPlaying. Bagaimana kalau kita ingin menjalankan side effect lain seperti

  • Mengirimkan event analytics saat player di-play
  • Menampilkan iklan saat player di-pause (eeww)
  • Meredupkan halaman saat di-play
  • … dan lain lain

Wah bakal terjadi banyak code coupling di dalam fungsi onPlayback nantinya karena ia tau terlalu banyak behavior dari elemen-elemen lain.

Kalau situasinya seperti ini, mungkin akan lebih baik jika semua side effect ini yang justru bereaksi terhadap perubahan state isPlaying. Jadi kita balik tanggungjawabnya. Kita ingin ada mekanisme dimana semua yang tertarik dengan nilai isPlaying bisa meninggalkan “nomor” mereka dan nomor-nomor ini akan ditelepon ketika terjadi perubahan terhadap nilai isPlaying.

Nomor-nomor ini berbentuk callback. Untuk menyembunyikan navbar saat player dimainkan, tinggal cantolin aja:

TypeScript
// Navbar.jquery.js
ui.subscribe((state) => {
  if (state.isPlaying) {
    $('#navbar').hide()
  } else {
    $('#navbar').show()
  }
})

Atau misal, aplikasi utama kita ditulis menggunakan React dan halaman akan diredupkan saat video sedang diputar dan iklan akan ditampilkan saat di-pause:

TypeScript
// MainPage.react.tsx
React.useEffect(function listenToPlayback() {
  const playerSubs = ui.subscribe((state) => {
    if (state.isPlaying) {
      dimPage(0.7)
    } else {
      showAds()
    }
  })

  return () => playerSubs.unsubscribe()
}, [])

Dan pada saat component unmount kita bisa unsubscribe agar bersih dari efek samping yang tak diinginkan.

Mekanisme subsciribe-unsubscribe inilah yang disebut Observer Pattern: playerUI sebagai observee, dan callback-callback ini sebagai observer-nya. Dipikir-pikir cara kerja Observer Pattern ini mirip seperti addEventListener(event, callback) dimana callback yang kita tinggalkan akan dijalankan saat suatu event terjadi.

Lalu bagaimana implementasinya?

TypeScript
 1function playerUI() {
 2  // ...
 3
 4  function togglePlay() {
 5    state.isPlaying = !state.isPlaying
 6    notifyListeners()
 7  }
 8
 9  type Listener = (st: State) => void
10  const listeners = new Set<Listener>()
11
12  function notifyListeners() {
13    listeners.forEach((cb) => cb(state))
14  }
15
16  function subscribe(cb: Listener) {
17    listeners.add(cb)
18
19    return { unsubscribe: () => listeners.delete(cb) }
20  }
21
22  return {
23    state,
24    togglePlay,
25    toggleTheme,
26    subscribe,
27  }
28}

Singkatnya, semua pihak yang tertarik dengan perubahan state harus menyediakan callback lewat fungsi subscribe. Callback-callback ini lalu didaftarkan ke dalam variable listeners. Dan ketika isPlaying berubah semuanya akan dijalankan. Jika tak terarik lagi, mereka bisa menjalankan fungsi unsubscribe.

Refactor

Jika kita perhatikan fungsi playerUI, fungsi ini menjalankan 2 hal yang berbeda sekaligus: mengelola listeners untuk reaktifitas, dan menyediakan behavior-nya sendiri (togglePlay dan toggleTheme). Harus kita pisahkan biar comply sama Single Responsibility Principle.

Pertama, mari kita ekstrak kode pengelolaan state ke dalam fungsi sendiri, sebut saja observable.

TypeScript
function observable<T>(initValue: T) {
  type Listener = (st: T) => void
  const listeners = new Set<Listener>()

  let value = initValue

  function get() {
    return value
  }

  function set(fn: (currValue: T) => T) {
    value = fn(value)
    notifyListeners()
  }

  function notifyListeners() {
    listeners.forEach((cb) => cb(value))
  }

  function subscribe(cb: Listener) {
    listeners.add(cb)

    return { unsubscribe: () => listeners.delete(cb) }
  }

  return { get, set, subscribe }
}

Di sini kita hanya menambahkan function get. Tapi kenapa harus ada fungsi ini sedangkan bisa saja kita langsung mengembalikan value di posisi return? Sebenarnya bisa aja, asalkan value berbentuk object. Kalau primitive value malah jadi trikcy. Contoh:

TypeScript
function observable<T>(initValue: T) {
  let value = initValue
  // ...

  return { value, set, subscribe }
}

const obs = observable(0)
obs.set((val) => val + 1)
obs.value // 0 ❌

Kok bisa nilainya gak ter-update jadi 1? Ternyata begitu set dipanggil, hanya variable value di dalam function observable saja yang berubah, namun tidak di posisi return. Karena ketika fungsi observable dijalankan pertama kali, object yang di-return dievaluasi menjadi

TypeScript
return {
  value: 0,
  set,
  subscribe,
}

Oleh karena itu kita butuh fungsi get untuk mendapatkan value secara lazy (tidak langsung dievaluasi ketika return).

TypeScript
const obs = observable(0)
obs.set((val) => val + 1)
obs.get() // 1 ✅

Dan fungsi playerUI pun menjadi:

TypeScript
function playerUI() {
  const { get, set, subscribe } = observable<State>({
    isPlaying: false,
    theme: 'light',
  })

  function togglePlay() {
    set((state) => ({
      ...state,
      isPlaying: !state.isPlaying
    }))
  }

  function toggleTheme() {
    set((state) => ({
      ...state,
      theme: state.theme === 'light' ? 'dark' : 'light'
    }))
  }

  return {
    getState: get,
    subscribe,
    togglePlay,
    toggleTheme,
  }
}

Saya lampirkan code di atas ke dalam link playground ini buat teman-teman yang mau ngulik lebih lanjut.

Kesimpulan

Observer Pattern memungkinkan kita untuk mendapat “notifikasi” update suatu value saat terjadi perubahan, macam push notification. Makanya Observer Pattern ini cocok untuk masalah yang sifatnya one-to-many tanpa harus khawatir dengan code coupling. Si listener-nya pun bisa sebanyak mungkin. Seperti push notification, kita bisa “ubah settingannya” untuk tidak menerima update-an lagi dengan memanggil fungsi unsubscribe.

Sekian dulu, saya harap artikel ini bermanfaat. Cheers.

Edit on