State adalah data yang sedang dipegang aplikasi saat ini: isi keranjang, siapa yang login, filter menu yang dipilih. State management adalah keputusan di mana data itu disimpan dan siapa yang boleh mengubahnya, supaya semua bagian halaman menunjukkan angka yang sama. Bayangin papan pesanan di dinding warung Martabak Bang Jali. Kasir, dapur, dan kurir semuanya membaca papan yang sama, dan cuma Bang Jali yang menulis di papan itu. Total di struk dihitung dari papan, bukan dicatat terpisah. Kalau kurir menyimpan hitungan sendiri di buku sakunya, cepat atau lambat hitungannya beda dengan papan.
🧪 Tebakan cepat: KeranjangStore menyimpan daftar
isi, tapi jugajumlah = signal(0)yang ditambah satu ditambah().buang()cuma menghapus dariisi. Pembeli memesan 2 martabak, lalu membuang 1. Header, yang membacajumlah(), menampilkan berapa item? Jawabannya ada di bagian "Satu sumber kebenaran".
State lokal dan state bersama
- State lokal cuma dipakai satu komponen, misalnya kartu menu yang bisa dibuka-tutup: cukup
terbuka = signal(false)di class-nya. - State bersama dibaca banyak komponen. Keranjang Bang Jali dibaca Header (badge jumlah), MenuPage (tombol Pesan), dan KeranjangPanel (daftar isi). Ketiganya gak saling kenal, jadi state-nya disimpan di satu service: sebuah store.
Signal store: service yang menjaga state
import { Injectable, computed, signal } from '@angular/core'
export interface Item {
nama: string
harga: number
}
@Injectable({ providedIn: 'root' })
export class KeranjangStore {
private isi = signal<Item[]>([])
readonly daftar = this.isi.asReadonly()
readonly jumlah = computed(() => this.isi().length)
readonly total = computed(() => {
console.log('total dihitung ulang')
return this.isi().reduce((t, item) => t + item.harga, 0)
})
tambah(item: Item) {
this.isi.update((isi) => [...isi, item])
}
buang(index: number) {
this.isi.update((isi) => isi.filter((_, i) => i !== index))
}
kosongkan() {
this.isi.set([])
}
}
providedIn: 'root': satu instance untuk seluruh aplikasi, seperti di pelajaran Services.private isi: cuma store yang bisa menulis ke signal ini.this.isi.asReadonly()membuka versi baca saja untuk komponen: bisa dibaca dengandaftar(), gak bisa di-set.jumlahdantotaladalahcomputed: diturunkan dariisi, gak pernah disimpan sendiri.tambah,buang, dankosongkanadalah satu-satunya cara mengubah keranjang, dan semuanya membuat array baru, bukanpush.
Dipakai di komponen
@Component({
selector: 'app-header',
standalone: true,
template: `<p class="badge">Keranjang: {{ store.jumlah() }} item, Rp{{ store.total() }}</p>`,
})
export class Header {
store = inject(KeranjangStore)
}
// template MenuPage: <button (click)="store.tambah(m)">Pesan</button>
// template KeranjangPanel: <button (click)="store.buang($index)">Buang</button>
| Kejadian | daftar() |
Header | Console |
|---|---|---|---|
| Aplikasi dibuka | [] |
Keranjang: 0 item, Rp0 | total dihitung ulang |
| Pesan Martabak Manis Cokelat Keju (40000) | 1 item | Keranjang: 1 item, Rp40000 | total dihitung ulang |
| Pesan Martabak Telur Spesial (50000) | 2 item | Keranjang: 2 item, Rp90000 | total dihitung ulang |
| Buang item pertama | 1 item | Keranjang: 1 item, Rp50000 | total dihitung ulang |
MenuPage gak pernah memberi tahu Header. Yang berubah cuma isi di store, dan computed menghitung ulang total sekali tiap kali isi berubah, lalu memakai hasilnya untuk semua yang membaca.
Satu sumber kebenaran
Versi tebakan tadi menyimpan nilai turunan sendiri:
readonly jumlah = signal(0)
readonly total = signal(0)
tambah(item: Item) {
this.isi.update((isi) => [...isi, item])
this.jumlah.update((n) => n + 1)
this.total.update((t) => t + item.harga)
}
buang(index: number) {
this.isi.update((isi) => isi.filter((_, i) => i !== index))
// lupa memperbarui jumlah dan total
}
| Kejadian | daftar() |
Header |
|---|---|---|
| Pesan 2 martabak | 2 item | Keranjang: 2 item, Rp90000 |
| Buang 1 | 1 item | Keranjang: 2 item, Rp90000 |
Header tetap 2 item padahal keranjangnya tinggal satu. Itu jawaban tebakannya. Ada dua sumber kebenaran, isi dan jumlah, dan setiap method harus ingat memperbarui keduanya. Begitu satu lupa, angkanya beda. Dengan computed, gak ada yang perlu diingat: jumlah dan total selalu dihitung dari isi.
asReadonly: yang boleh mengubah cuma store
Kalau KeranjangPanel menulis this.store.daftar.set([]), proyek CLI gagal dikompilasi:
Property 'set' does not exist on type 'Signal<Item[]>'.
Komponen cuma bisa meminta perubahan lewat method store, misalnya this.store.kosongkan(). Jadi kalau keranjang tiba-tiba kosong, cukup cari siapa yang memanggil kosongkan().
Kapan NgRx
Signals adalah fitur bawaan Angular (sejak versi 16, stabil sejak 17), dan service dengan signal seperti KeranjangStore cukup untuk kebanyakan aplikasi. NgRx adalah library state management populer untuk Angular yang terinspirasi Redux: semua state ada di satu Store, diubah cuma lewat action yang diproses reducer, dan dibaca lewat selector.
// gaya NgRx (library terpisah, gak ada di Playground)
export const tambah = createAction('[Menu] Tambah', props<{ item: Item }>())
export const keranjangReducer = createReducer(
{ isi: [] as Item[] },
on(tambah, (state, { item }) => ({ ...state, isi: [...state.isi, item] })),
)
// di komponen: this.store.dispatch(tambah({ item: m }))
| Kebutuhan | Pilihan |
|---|---|
| dipakai satu komponen | signal di komponen itu |
| dibagi beberapa komponen | signal store seperti KeranjangStore |
| alur asinkron rumit (debounce, membatalkan request lama) | service + RxJS |
| state kompleks yang dibagi banyak komponen, tim besar, perlu jejak setiap perubahan di DevTools | NgRx |
Mulai dari signal. Naik ke NgRx kalau manfaatnya jelas, karena tiap perubahan butuh action, reducer, dan selector sendiri.
⚠️ Jebakan yang sering ketemu
- Menyimpan nilai turunan sendiri.
jumlahdantotalsebagai signal terpisah pasti suatu saat tertinggal. Turunkan dengancomputed. - Signal store yang bisa ditulis siapa saja.
isi = signal(...)yang public membuat komponen mana pun bisasetdiam-diam. Buatprivate, bukaasReadonly(). pushke array di dalam signal.computedgak tahu ada perubahan. Pakaiupdate((isi) => [...isi, item]).- Detail latihan pertama.
TodoStorediberi@Injectable({ providedIn: 'root' }),private items = signal<string[]>([]),readonly todoItems = this.items.asReadonly(),readonly count = computed(() => this.items().length),addItem(item: string)danremoveItem(index: number)yang mengubahitemsdengan.update(. Penilai membuat store baru, memanggiladdItem('a'),addItem('b'),removeItem(0), lalu mengharapcount()bernilai 1. - Detail latihan kedua.
CartStoremenurunkantotaldarireduceharga di dalamcomputed, danclearCart()menulisthis.items.set([]). Penilai menambah barang seharga 3 dan 4 lalu mengharaptotal()7, dan sesudahclearCart()mengharap 0.
TL;DR: State bersama, seperti keranjang yang dibaca Header, MenuPage, dan KeranjangPanel, disimpan di satu service providedIn: 'root': sebuah signal store. Simpan datanya di private isi = signal(...), buka asReadonly() untuk dibaca, turunkan jumlah dan total dengan computed supaya ada satu sumber kebenaran, dan ubah lewat method yang membuat array baru. Nilai turunan yang disimpan terpisah pasti tertinggal. Signals adalah fitur bawaan Angular yang cukup untuk kebanyakan state; NgRx, library bergaya Redux, dipakai untuk state kompleks yang dibagi banyak komponen di aplikasi besar.