State Pattern — Design Patterns

State Pattern membuat object mengubah perilakunya saat internal state-nya berubah. Seolah-olah object "berganti class" di runtime. Cocok untuk state machine…

State Pattern menaruh semua aturan satu keadaan (state) di satu object: aksi apa yang boleh, ke keadaan mana dia boleh pindah, dan apa yang terjadi saat aksi dijalankan. Object utamanya cuma meneruskan tiap aksi ke object state yang sedang aktif, jadi perilakunya ikut berubah saat state-nya berganti. Bayangin palang pintu parkir. Saat menunggu karcis, tombol "buka" gak bereaksi. Setelah karcis dibayar, tombol yang sama membuka palang. Saat palang terbuka, memasukkan karcis lagi ditolak. Tombolnya sama, reaksinya bergantung keadaan palang, dan palang gak bisa melompat dari "menunggu karcis" langsung ke "terbuka".

🧪 Tebakan cepat: paket KCL-0001 sudah terkirim. Kurir salah memindai, dan aplikasinya memanggil ubahStatus(p, "dijemput") versi lama yang cuma menulis p.status = baru. SMS apa yang diterima pelanggan, dan progres berapa yang tampil di halaman lacak? Tebak dulu, jawabannya ada di bagian "Transisi yang sah, dan yang ditolak".

Masalahnya: status dibaca di empat function

Kurir Kancil punya empat status: dijemput, transit, diantar, terkirim. Versi pertama membaca status di empat function. Tiga punya switch sendiri, dan ubahStatus gak memeriksa apa pun:

function ubahStatus(p, baru) {
  p.status = baru // gak ada pengecekan apa pun
  return "SMS: " + pesanSms(p)
}
function pesanSms(p) {
  switch (p.status) {
    case "dijemput": return p.resi + " sudah dijemput kurir"
    case "transit": return p.resi + " tiba di gudang transit"
    case "diantar": return p.resi + " sedang diantar kurir"
    case "terkirim": return p.resi + " sudah diterima"
  }
}
function progres(p) {
  switch (p.status) {
    case "dijemput": return 25
    case "transit": return 50
    case "diantar": return 75
    case "terkirim": return 100
  }
}
function ubahAlamat(p, alamat) {
  switch (p.status) {
    case "dijemput":
    case "transit":
      p.alamat = alamat
      return "alamat diubah: " + alamat
    default:
      return "ditolak: kurir sudah berangkat"
  }
}

Status baru retur (dikembalikan ke pengirim) berarti membuka ketiga switch. Satu terlupa, misalnya pesanSms, dan pelanggan menerima SMS: undefined, tanpa error.

State object: tiap status tahu aturannya sendiri

const STATES = {
  dijemput: {
    boleh: ["transit"],
    progres: 25,
    sms: (resi) => resi + " sudah dijemput kurir",
    ubahAlamat: (p, alamat) => { p.alamat = alamat; return "alamat diubah: " + alamat },
  },
  transit: {
    boleh: ["diantar"],
    progres: 50,
    sms: (resi) => resi + " tiba di gudang transit",
    ubahAlamat: (p, alamat) => { p.alamat = alamat; return "alamat diubah: " + alamat },
  },
  diantar: {
    boleh: ["terkirim", "transit"], // gagal antar: kembali ke gudang
    progres: 75,
    sms: (resi) => resi + " sedang diantar kurir",
    ubahAlamat: () => "ditolak: kurir sudah berangkat",
  },
  terkirim: {
    boleh: [], // akhir perjalanan
    progres: 100,
    sms: (resi) => resi + " sudah diterima",
    ubahAlamat: () => "ditolak: paket sudah terkirim",
  },
}

function createParcel(resi) {
  let status = "dijemput"
  return {
    resi,
    alamat: "Jl. Kenari 5",
    getStatus: () => status,
    getProgres: () => STATES[status].progres,
    ubahStatus(baru) {
      if (!STATES[status].boleh.includes(baru)) return "ditolak: " + status + " → " + baru
      status = baru
      return "SMS: " + STATES[status].sms(resi)
    },
    ubahAlamat(alamat) {
      return STATES[status].ubahAlamat(this, alamat)
    },
  }
}

Transisi yang sah, dan yang ditolak

const p = createParcel("KCL-0001")
console.log(p.ubahAlamat("Jl. Merak 9"))  // alamat diubah: Jl. Merak 9
console.log(p.ubahStatus("transit"))      // SMS: KCL-0001 tiba di gudang transit
console.log(p.ubahStatus("terkirim"))     // ditolak: transit → terkirim
console.log(p.ubahStatus("diantar"))      // SMS: KCL-0001 sedang diantar kurir
console.log(p.ubahAlamat("Jl. Elang 2"))  // ditolak: kurir sudah berangkat
console.log(p.ubahStatus("terkirim"))     // SMS: KCL-0001 sudah diterima
console.log(p.ubahStatus("dijemput"))     // ditolak: terkirim → dijemput
status sebelum aksi hasil status sesudah
dijemput ubahStatus("transit") SMS terkirim transit
transit ubahStatus("terkirim") ditolak, diantar dilompati transit
diantar ubahAlamat(...) ditolak, kurir sudah berangkat diantar
terkirim ubahStatus("dijemput") ditolak, perjalanan sudah selesai terkirim

Versi lama menerima semuanya. KCL-0001 yang sudah terkirim lalu dipindai dijemput membuat pelanggan menerima SMS: KCL-0001 sudah dijemput kurir, dan halaman lacak mundur ke progres 25%. Itu jawaban tebakannya. Mesin status menolaknya dengan ditolak: terkirim → dijemput, tanpa SMS. Lompatan transit ke terkirim dari lesson Command juga sekarang ditolak.

State atau Strategy?

Bentuk keduanya mirip: object berisi function, lalu kode utama meneruskan panggilan ke salah satunya. Bedanya di siapa yang menggantinya:

Latihan: vending machine

Latihan nanti memakai bentuk yang sama untuk mesin minuman. createMachine(stock) mengembalikan { insertCoin, selectProduct, getState }, dengan tiga state:

state insertCoin() selectProduct()
"NoMoney" pindah ke "HasMoney", kembalikan "coin accepted" "insert coin first"
"HasMoney" "coin already inserted" kurangi stok, pindah ke "SoldOut" kalau stok jadi 0 atau "NoMoney" kalau masih ada, kembalikan "dispensed"
"SoldOut" "machine sold out" "machine sold out"

State awalnya "NoMoney" kalau stok lebih dari 0, "SoldOut" kalau 0. Untuk createMachine(1), console mencetak insert coin first, coin accepted, dispensed, lalu getState() mencetak SoldOut. Dengan stok 2, urutan pilih, koin, pilih, koin, pilih menghasilkan insert coin first, coin accepted, dispensed, coin accepted, dispensed, dan state akhirnya SoldOut.

Kapan pakai, kapan jangan

⚠️ Jebakan yang sering ketemu

TL;DR: State Pattern menaruh aturan tiap keadaan di satu object (boleh, progres, sms, ubahAlamat), dan object utama meneruskan tiap aksi ke state yang sedang aktif, jadi switch yang tersebar di empat function hilang dan status baru cukup satu object. boleh menegakkan transisi yang sah: terkirim → dijemput ditolak, sedangkan versi lama mengirim SMS "sudah dijemput" dan memundurkan progres ke 25%. Strategy dipilih dari luar oleh pemanggil, State berganti sendiri sebagai akibat aksi. Di latihan: NoMoney, HasMoney, dan SoldOut masing-masing menjawab insertCoin dan selectProduct dengan pesannya sendiri, dan getState() mengembalikan nama state. Untuk dua keadaan sederhana, boolean sudah cukup.

Yang akan kamu pelajari