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 memanggilubahStatus(p, "dijemput")versi lama yang cuma menulisp.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)
},
}
}
- Tiap object di
STATESpunya bagian yang sama:boleh,progres,sms,ubahAlamat. Semua yang dulu tersebar di empat function sekarang ada di satu tempat per status. bolehadalah daftar transisi yang sah: status yang boleh dituju dari status ini.terkirimpunya daftar kosong, jadi gak bisa ke mana-mana.createParcelmenyimpan status di variabelstatusdi dalam closure, jadi gak bisa ditulis langsung dari luar. Satu-satunya jalan adalahubahStatus.ubahAlamatgak punyaifsama sekali. Dia meneruskan keSTATES[status].ubahAlamat, dan object state yang sedang aktif yang menentukan reaksinya. Itu inti State Pattern: method yang sama, perilaku yang berganti mengikuti state.- Status
returcukup satu object baru diSTATESdan"retur"dibolehmilikdiantar. Kalau object barunya lupa diberisms, program langsung berhenti denganTypeError, bukan diam-diam mengirimundefined.
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:
- Strategy dipilih dari luar oleh pemanggil:
checkout("kilat", 2, PROMO.diskon10). Pemanggil yang memutuskan promo mana. - State berganti sendiri sebagai akibat aksi:
p.ubahStatus("diantar")membuatubahAlamatmulai menolak. Gak ada pemanggil yang memilih object state-nya.
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
- Pakai saat aksi yang sama bereaksi beda per keadaan dan ada transisi yang harus ditolak: status paket, mesin minuman, atau checkout bertahap Cart → Shipping → Payment → Confirmation, di mana tiap tahap punya aksi sah yang berbeda. Library seperti XState menyediakan mesin status lengkap dengan diagramnya.
- Jangan untuk dua keadaan sederhana:
asuransi: trueataufalsecukup boolean danif. - Jangan kalau yang berubah cuma nilai, tanpa aturan atau perilaku yang beda: tabel data sudah cukup.
⚠️ Jebakan yang sering ketemu
- Menulis status langsung.
p.status = "dijemput"di luar mesin melewati semua pengecekan. Simpan status di closure, dan ubah cuma lewatubahStatus. - Lupa satu
case. Versiswitchdengan status baru yang terlupa dipesanSmsmengirimSMS: undefinedtanpa error. - Lupa pindah state. Di latihan,
selectProductyang mengurangi stok tapi gak memindahkan state membuat koin berikutnya dijawabcoin already inserted. - State awal yang salah.
createMachine(0)harus mulai di"SoldOut", bukan"NoMoney".
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.