Reactivity and Change Propagation
Learn Advanced JavaScript for Web / Frontend Engineering - Part 011
Reactivity and change propagation as a production engineering discipline: dependency graphs, signals, derived state, effects, batching, schedulers, glitches, and debugging reactive systems.
Part 011 — Reactivity and Change Propagation
Di part sebelumnya, kita membahas state modeling: bagaimana membedakan source state, derived state, server state, URL state, workflow state, dan effect state. Part ini menjawab pertanyaan lanjutannya:
Setelah state berubah, bagaimana perubahan itu bergerak ke seluruh UI tanpa membuat sistem menjadi lambat, tidak deterministik, atau sulit di-debug?
Inilah domain reactivity and change propagation.
Frontend engineer biasa sering melihat reactivity sebagai fitur framework: useState, computed, watch, signal, store, atau observable. Engineer yang lebih matang melihatnya sebagai mekanisme propagasi perubahan di atas graph dependensi.
Reactivity bukan tujuan. Reactivity adalah cara menjaga konsistensi antara:
- source state;
- derived state;
- rendered output;
- side effect;
- external system seperti network, storage, analytics, URL, worker, dan DOM imperative.
Kalau graph ini tidak dirancang, aplikasi akan tetap berjalan, tetapi akan muncul gejala berikut:
- render berulang tanpa alasan jelas;
- derived state drift dari source state;
- watcher/effect saling memicu;
- cache tidak invalid;
- race antara user input dan response server;
- UI terlihat benar di happy path tetapi rusak pada interaksi cepat;
- bug sulit direproduksi karena urutan update bergantung timing.
Part ini memakai pendekatan Kaufman: kita pecah skill reactivity menjadi sub-skill yang bisa dilatih, cukup teori untuk self-correction, lalu masuk ke failure modes dan practice loop.
1. Target Skill
Setelah menyelesaikan part ini, targetnya bukan sekadar tahu perbedaan React, Vue, Solid, atau RxJS. Targetnya adalah mampu:
- membaca UI sebagai dependency graph;
- menentukan mana state yang harus disimpan dan mana yang harus diturunkan;
- memilih model propagasi perubahan: pull, push, hybrid, render-based, fine-grained, observable, atau explicit event;
- membedakan derivation dan effect;
- mendiagnosis rerender, stale value, glitch, infinite loop, dan over-subscription;
- mendesain reactive boundary yang aman untuk production.
Skill ini penting karena mayoritas bug frontend kompleks bukan berasal dari syntax, tetapi dari perubahan state yang menyebar tanpa model yang jelas.
2. Mental Model Utama
Reactivity adalah sistem yang menjawab empat pertanyaan:
| Pertanyaan | Makna Engineering |
|---|---|
| Apa yang berubah? | Source of mutation |
| Siapa yang bergantung pada nilai itu? | Dependency tracking |
| Kapan perubahan diproses? | Scheduling and batching |
| Apa yang harus dijalankan ulang? | Invalidation and recomputation |
Secara konseptual:
Model ini berlaku meskipun framework berbeda.
- React cenderung memakai model render-driven invalidation.
- Vue memakai reactive proxy dan dependency tracking.
- Solid memakai fine-grained signals.
- RxJS memakai stream/observable push model.
- Vanilla DOM bisa reactive bila kita membangun mekanisme subscription sendiri.
Perbedaannya bukan pada “mana yang modern”, tetapi pada:
- granularitas tracking;
- kapan update dijalankan;
- apakah dependency diketahui secara eksplisit atau otomatis;
- apakah side effect terkontrol;
- seberapa mudah sistem di-debug saat skala membesar.
3. Decomposition ala Kaufman
Untuk menguasai reactivity, pecah menjadi tujuh sub-skill:
| Sub-skill | Pertanyaan Latihan | Output yang Diharapkan |
|---|---|---|
| Dependency modeling | Nilai apa bergantung pada nilai apa? | Dependency graph eksplisit |
| Derivation discipline | Apakah nilai ini harus disimpan atau dihitung? | Minim duplicated state |
| Effect isolation | Apakah operasi ini pure atau menyentuh dunia luar? | Effect ditempatkan di edge |
| Scheduling awareness | Kapan update dijalankan? | Tidak ada timing assumption palsu |
| Invalidation design | Apa yang menjadi stale saat input berubah? | Cache dan memo invalid dengan benar |
| Debugging propagation | Mengapa ini rerender/ter-update? | Diagnosis path perubahan |
| Failure modeling | Bagaimana graph ini bisa loop, glitch, atau drift? | Guard dan invariant |
Jangan mulai dari API framework. Mulai dari graph.
4. Vocabulary yang Harus Presisi
4.1 Source State
Source state adalah data yang memiliki otoritas. Ia tidak bisa dihitung sepenuhnya dari state lain di client.
Contoh:
const selectedUserId = "u-123";
const usersById = new Map();
const currentRoute = "/users/u-123";
Source state harus dijaga seminimal mungkin. Semakin banyak source state, semakin banyak kemungkinan inkonsistensi.
4.2 Derived State
Derived state adalah nilai yang dapat dihitung dari source state.
const selectedUser = usersById.get(selectedUserId);
const isAdmin = selectedUser?.roles.includes("admin") ?? false;
Kalau derived state disimpan sebagai source state tambahan, kita membuat peluang drift.
Bad smell:
let selectedUserId = "u-123";
let selectedUser = usersById.get(selectedUserId);
// selectedUserId berubah, selectedUser lupa diperbarui.
selectedUserId = "u-456";
Better:
function getSelectedUser(state: State) {
return state.usersById.get(state.selectedUserId) ?? null;
}
4.3 Dependency
Dependency adalah relasi: “nilai A membutuhkan nilai B untuk dihitung”.
Kalau firstName berubah, fullName menjadi stale, lalu greeting juga stale.
4.4 Invalidation
Invalidation bukan recomputation. Invalidation berarti menandai nilai sebagai tidak lagi dapat dipercaya.
source changes -> dependents become stale -> scheduler decides recompute timing
Sistem yang baik tidak selalu langsung recompute semua hal. Ia tahu kapan cukup menandai stale, kapan harus recompute, dan kapan harus batch.
4.5 Effect
Effect adalah operasi yang menyentuh dunia luar atau punya konsekuensi di luar proses komputasi pure.
Contoh effect:
- fetch API;
- menulis ke
localStorage; - push analytics event;
- update
document.title; - subscribe ke WebSocket;
- imperatively focus input;
- mutate DOM di luar renderer.
Rule penting:
Derived value harus pure. Effect harus berada di boundary.
Kalau derived value melakukan effect, graph menjadi sulit diprediksi.
5. Pull, Push, dan Hybrid Reactivity
Ada tiga keluarga besar propagasi perubahan.
5.1 Pull Model
Dalam pull model, consumer meminta nilai saat dibutuhkan.
function getTotal(items: Item[]) {
return items.reduce((sum, item) => sum + item.price, 0);
}
Karakteristik:
- sederhana;
- mudah diuji;
- tidak butuh subscription;
- bisa boros kalau komputasi mahal dan sering dipanggil.
Pull model cocok untuk derived value murah dan synchronous.
5.2 Push Model
Dalam push model, producer mengirim perubahan ke subscriber.
class Store<T> {
private value: T;
private listeners = new Set<(value: T) => void>();
constructor(initialValue: T) {
this.value = initialValue;
}
get() {
return this.value;
}
set(next: T) {
if (Object.is(this.value, next)) return;
this.value = next;
for (const listener of this.listeners) {
listener(next);
}
}
subscribe(listener: (value: T) => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
Karakteristik:
- responsif;
- bagus untuk event stream;
- butuh lifecycle unsubscribe;
- rentan cascading update dan infinite loop.
Push model cocok untuk external event, WebSocket, user event, observable stream, atau state global yang perlu memberi tahu banyak consumer.
5.3 Hybrid Model
Hybrid model menggabungkan invalidation push dengan recomputation pull.
Contoh mental model:
source.set(next)
-> mark derived value stale
-> consumer reads derived value
-> recompute lazily if stale
Model ini umum pada computed/memo/signal system.
Keuntungan:
- tidak recompute kalau tidak dibaca;
- dependency tetap diketahui;
- bisa menghindari pekerjaan sia-sia.
Risiko:
- butuh dependency tracking akurat;
- stale flag harus benar;
- effect tidak boleh membaca/menulis secara kacau.
6. Mini Reactive System dari Nol
Untuk memahami framework, kita bangun versi sederhana. Tujuannya bukan membuat library, tetapi memahami mekanisme.
6.1 Signal Minimal
type Subscriber = () => void;
function createSignal<T>(initialValue: T) {
let value = initialValue;
const subscribers = new Set<Subscriber>();
function get() {
if (activeSubscriber) {
subscribers.add(activeSubscriber);
}
return value;
}
function set(nextValue: T) {
if (Object.is(value, nextValue)) return;
value = nextValue;
for (const subscriber of subscribers) {
subscriber();
}
}
return [get, set] as const;
}
let activeSubscriber: Subscriber | null = null;
function createEffect(fn: () => void) {
const subscriber = () => {
activeSubscriber = subscriber;
try {
fn();
} finally {
activeSubscriber = null;
}
};
subscriber();
}
Pemakaian:
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("count changed:", count());
});
setCount(1);
setCount(2);
Apa yang terjadi?
createEffectmenjalankan function.- Saat
count()dibaca, signal tahu siapa subscriber aktif. - Saat
setCountdipanggil, subscriber dijalankan ulang.
Ini inti dependency tracking otomatis.
6.2 Problem: Subscription Lama Tidak Dibersihkan
Versi di atas punya bug.
const [flag, setFlag] = createSignal(true);
const [a, setA] = createSignal(1);
const [b, setB] = createSignal(10);
createEffect(() => {
console.log(flag() ? a() : b());
});
Saat flag true, effect membaca a. Saat flag berubah ke false, effect membaca b. Tetapi versi sederhana tadi tidak menghapus subscription lama ke a. Akibatnya setA masih bisa menjalankan effect walau a tidak lagi relevan.
Framework reactivity yang matang harus menangani dynamic dependency cleanup.
6.3 Problem: Nested Effect
createEffect(() => {
createEffect(() => {
console.log(count());
});
});
Kalau active subscriber hanya satu global variable, nested effect akan menimpa subscriber luar. Sistem yang benar butuh stack.
const subscriberStack: Subscriber[] = [];
function runWithSubscriber(subscriber: Subscriber, fn: () => void) {
subscriberStack.push(subscriber);
try {
fn();
} finally {
subscriberStack.pop();
}
}
function getActiveSubscriber() {
return subscriberStack[subscriberStack.length - 1] ?? null;
}
Ini contoh kecil mengapa reactivity terlihat mudah tetapi rumit di production.
7. Derived Values dan Memoization
Derived value harus pure.
const subtotal = computed(() => {
return cartItems().reduce((sum, item) => sum + item.price * item.qty, 0);
});
Derived value yang baik:
- hanya membaca dependency;
- tidak mutate state lain;
- tidak fetch;
- tidak subscribe manual;
- deterministic untuk input yang sama;
- bisa di-cache;
- aman dijalankan ulang.
Derived value yang buruk:
const total = computed(() => {
analytics.track("total_computed");
localStorage.setItem("lastTotal", String(subtotal()));
return subtotal() + tax();
});
Kenapa buruk?
- komputasi menjadi effectful;
- setiap recomputation mengirim analytics;
- memoization menjadi berbahaya;
- testing lebih sulit;
- urutan eksekusi menjadi observable.
Pisahkan:
const total = computed(() => subtotal() + tax());
effect(() => {
localStorage.setItem("lastTotal", String(total()));
});
Bahkan ini pun perlu hati-hati: apakah setiap perubahan total memang harus menulis ke storage? Apakah perlu debounce? Apakah perlu guard hydration? Apakah storage tersedia?
8. Effects Harus di Edge
Effect adalah boundary antara graph internal dan dunia luar.
Rule praktis:
Kalau sebuah function membuat hasil yang bisa diuji hanya dengan input-output, itu derivation. Kalau function membutuhkan waktu, I/O, environment, atau cleanup, itu effect.
Contoh effect dengan cleanup:
function subscribeToRoom(roomId: string, onMessage: (msg: Message) => void) {
const socket = new WebSocket(`/rooms/${roomId}`);
socket.addEventListener("message", event => {
onMessage(JSON.parse(event.data));
});
return () => socket.close();
}
Dalam reactive UI, effect harus punya lifecycle:
- kapan dibuat;
- dependency apa yang membuatnya dibuat ulang;
- kapan cleanup dijalankan;
- apa yang terjadi jika dependency berubah cepat;
- bagaimana race dicegah.
Bad smell:
watch(searchTerm, async term => {
const result = await fetch(`/api/search?q=${term}`).then(r => r.json());
setResults(result);
});
Masalah:
- request lama bisa selesai setelah request baru;
- tidak ada cancellation;
- tidak ada debounce;
- tidak ada error state;
- tidak ada loading state yang reliable.
Better shape:
let currentRequestId = 0;
async function search(term: string, signal: AbortSignal) {
const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal });
if (!response.ok) throw new Error("Search failed");
return response.json() as Promise<SearchResult[]>;
}
function createSearchEffect(getTerm: () => string, setState: (state: SearchState) => void) {
let cleanup: (() => void) | null = null;
return effect(() => {
cleanup?.();
const term = getTerm().trim();
if (term.length < 2) {
setState({ status: "idle", results: [] });
return;
}
const requestId = ++currentRequestId;
const controller = new AbortController();
cleanup = () => controller.abort();
setState({ status: "loading", results: [] });
search(term, controller.signal)
.then(results => {
if (requestId !== currentRequestId) return;
setState({ status: "success", results });
})
.catch(error => {
if (controller.signal.aborted) return;
if (requestId !== currentRequestId) return;
setState({ status: "error", error });
});
});
}
Intinya: reactive effect tidak boleh naif terhadap waktu.
9. Batching dan Scheduling
Tanpa batching:
setFirstName("Ada");
setLastName("Lovelace");
setAge(36);
Tiga mutation bisa memicu tiga propagasi.
Dengan batching:
batch(() => {
setFirstName("Ada");
setLastName("Lovelace");
setAge(36);
});
Satu flush cukup untuk semua perubahan.
9.1 Kenapa Batching Penting?
Batching menjaga:
- performance;
- konsistensi snapshot;
- jumlah render;
- jumlah effect;
- stabilitas input handling.
Tanpa batching, consumer bisa melihat intermediate state.
setStartDate("2026-06-01");
setEndDate("2026-05-01");
Selama beberapa saat, invariant startDate <= endDate bisa rusak jika update tidak atomic.
Better:
setDateRange({
start: "2026-06-01",
end: "2026-06-30"
});
Atau gunakan transaction.
9.2 Scheduler Bukan Detail Implementasi
Scheduler menentukan kapan update dijalankan:
| Scheduler | Cocok Untuk | Risiko |
|---|---|---|
| Immediate | State kecil, invariant harus langsung valid | Cascading update |
| Microtask | Batch update setelah call stack | Microtask starvation |
| Animation frame | Visual update | Latency untuk non-visual work |
| Idle callback | Low-priority work | Tidak guaranteed segera jalan |
| Explicit queue | Workflow kompleks | Butuh governance |
Frontend production harus sadar scheduler karena user interaction, rendering, network, dan animation semua berbagi resource.
10. Glitch dan Diamond Dependency
Glitch terjadi ketika derived value melihat kombinasi dependency yang tidak konsisten.
Jika count berubah dari 1 ke 2, idealnya:
double = 4
triple = 6
sum = 10
Tetapi sistem yang buruk bisa sementara menghitung:
double = 4
triple = 3
sum = 7
Ini glitch: derived value membaca kombinasi lama dan baru.
Cara mencegah:
- topological ordering;
- batching;
- transaction;
- lazy recomputation;
- versioning;
- single snapshot per render.
11. Reactivity vs Rendering
Jangan samakan reactivity dengan rendering.
Reactivity menjawab:
Nilai mana berubah dan siapa bergantung padanya?
Rendering menjawab:
Bagaimana perubahan itu menjadi output visual?
Beberapa framework menggabungkan keduanya. Beberapa memisahkan.
| Model | Unit Update | Mental Model |
|---|---|---|
| Render-based component model | Component/subtree | State change schedules render |
| Fine-grained signal model | Specific computation/binding | Dependency change reruns exact consumer |
| Observable stream | Event value over time | Subscriber reacts to emitted value |
| Manual DOM | Arbitrary imperative code | Developer menentukan update |
11.1 Render-Based Model
Dalam render-based model, perubahan state menjadwalkan render ulang component/subtree.
Keuntungan:
- mental model deklaratif;
- render adalah pure projection dari state;
- mudah compose component;
- debugging bisa dimulai dari state -> render.
Risiko:
- render terlalu besar;
- memoization salah sasaran;
- referential equality menjadi penting;
- derived data dihitung ulang berlebihan;
- child component ikut render karena parent render.
11.2 Fine-Grained Model
Dalam fine-grained model, dependency tracking dilakukan pada level computation kecil.
Keuntungan:
- update sangat presisi;
- tidak selalu butuh virtual DOM diff;
- derived values bisa lazy;
- cocok untuk UI data-dense.
Risiko:
- graph dependency lebih eksplisit/rumit;
- effect lifecycle harus disiplin;
- debug graph bisa sulit tanpa tooling;
- overuse signal bisa membuat architecture fragmented.
12. Proxy-Based Reactivity
Proxy-based reactivity melacak access property pada object.
Contoh simplified:
const targetMap = new WeakMap<object, Map<PropertyKey, Set<Subscriber>>>();
let activeEffect: Subscriber | null = null;
function track(target: object, key: PropertyKey) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
dep.add(activeEffect);
}
function trigger(target: object, key: PropertyKey) {
const depsMap = targetMap.get(target);
const dep = depsMap?.get(key);
if (!dep) return;
for (const effect of dep) {
effect();
}
}
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key);
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const oldValue = Reflect.get(obj, key, receiver);
const result = Reflect.set(obj, key, value, receiver);
if (!Object.is(oldValue, value)) {
trigger(obj, key);
}
return result;
}
});
}
Proxy-based reactivity memungkinkan:
state.user.name = "Ada";
Tetapi mutasi nested juga membawa risiko:
- identity sulit dikontrol;
- debugging mutation path lebih sulit;
- deep reactivity bisa mahal;
- serialization/hydration perlu hati-hati;
- object dari luar boundary bisa ikut reactive secara tidak sengaja.
Rule praktis:
Gunakan mutasi ergonomis bila framework mendukung, tetapi tetap desain ownership dan boundary secara eksplisit.
13. Observable dan Stream Thinking
Observable/stream cocok untuk nilai yang berubah sepanjang waktu.
Contoh domain:
- input event;
- WebSocket messages;
- drag gesture;
- polling;
- media events;
- real-time validation;
- collaborative cursor;
- background sync.
Stream thinking melihat perubahan sebagai sequence:
--a----b-c-----d---->
Operator seperti map, filter, debounce, throttle, switchMap, merge, concat, dan retry bukan sekadar utility. Mereka adalah cara mendesain time semantics.
Contoh search:
input: a--ad--ada-------ada l---->
debounce: ----ad-----ada-------ada l->
switchMap: cancel old request on new query
switchMap secara konsep berarti:
Ambil request terbaru dan batalkan/abaikan hasil lama.
Ini sangat penting untuk UI yang menerima input cepat.
14. Reactivity untuk Server State
Server state berbeda dari local state.
Local state:
- dimiliki client;
- update synchronous;
- lifespan biasanya pendek;
- invalidation lokal.
Server state:
- dimiliki server;
- asynchronous;
- bisa stale;
- bisa dibagi antar tab/user;
- butuh cache, revalidation, deduplication, retry, dan invalidation.
Jangan perlakukan server state sebagai local signal biasa.
Bad smell:
const [users, setUsers] = createSignal<User[]>([]);
async function loadUsers() {
setUsers(await fetchUsers());
}
Ini cukup untuk demo, tetapi kurang untuk production karena tidak menjawab:
- apakah data stale?;
- apakah request sedang berjalan?;
- apakah ada error?;
- apakah request duplicate?;
- kapan cache invalid?;
- bagaimana optimistic update rollback?;
- bagaimana pagination?;
- bagaimana partial failure?;
- bagaimana authorization berubah?;
- bagaimana tab lain mengubah data?
Production shape:
type RemoteData<T> =
| { status: "idle" }
| { status: "loading"; previous?: T }
| { status: "success"; data: T; fetchedAt: number; stale: boolean }
| { status: "error"; error: Error; previous?: T };
Server state adalah reactive, tetapi reactivity-nya harus memasukkan time, freshness, and authority.
15. State Machine dan Reactivity
State machine sering lebih aman daripada reactive flags lepas.
Bad:
const isLoading = signal(false);
const isError = signal(false);
const isSuccess = signal(false);
const data = signal<User[] | null>(null);
Kombinasi invalid mungkin terjadi:
isLoading = true
isError = true
isSuccess = true
data = null
Better:
type UsersState =
| { tag: "idle" }
| { tag: "loading" }
| { tag: "success"; data: User[] }
| { tag: "error"; error: Error };
Reactive graph menjadi lebih sederhana karena satu source state menjaga invariant.
Derived values:
const canRetry = computed(() => usersState().tag === "error");
const showSkeleton = computed(() => usersState().tag === "loading");
const users = computed(() => usersState().tag === "success" ? usersState().data : []);
16. Referential Equality dan Change Detection
Banyak sistem reactivity memakai equality untuk menentukan apakah update perlu disebarkan.
Object.is(oldValue, nextValue)
Masalah umum:
const user = getUser();
user.name = "Ada";
setUser(user);
Jika equality berbasis reference, update bisa tidak terdeteksi karena reference sama.
Better immutable update:
setUser({
...user,
name: "Ada"
});
Tetapi immutability juga punya trade-off:
- object allocation lebih banyak;
- nested update verbose;
- memoization bergantung identity;
- over-copying bisa mahal.
Rule:
Pilih mutation model sesuai framework, tetapi jangan campur tanpa boundary jelas.
Kalau satu layer memakai immutable identity dan layer lain memakai mutable proxy, bug identity bisa muncul.
17. Over-Reactivity
Tidak semua hal harus reactive.
Contoh over-reactivity:
- menyimpan nilai konstan sebagai signal;
- membuat signal untuk setiap field kecil tanpa alasan;
- membuat watcher untuk derivation sederhana;
- menaruh server cache di global reactive store tanpa freshness model;
- membuat effect untuk sync dua state yang seharusnya satu source state;
- memasukkan function/object baru ke dependency tanpa stabilisasi.
Bad:
watch(firstName, value => {
fullName.set(`${value} ${lastName.get()}`);
});
watch(lastName, value => {
fullName.set(`${firstName.get()} ${value}`);
});
Better:
const fullName = computed(() => `${firstName()} ${lastName()}`);
Prinsip:
Kalau sesuatu bisa dihitung, hitung. Jangan disinkronkan manual.
18. Under-Reactivity
Under-reactivity terjadi ketika dependency berubah tetapi consumer tidak update.
Contoh:
const user = state.usersById.get(userId);
Jika usersById adalah Map, tidak semua sistem reactivity otomatis melacak operasi get, set, delete, iterasi, dan size. Perlu pahami framework.
Masalah lain:
const value = props.user.name;
function onClick() {
console.log(value);
}
Jika props.user.name berubah tetapi value disimpan di closure lama, handler bisa membaca stale value tergantung framework dan lifecycle.
Diagnosis:
- Apakah dependency benar-benar dibaca dalam reactive scope?
- Apakah mutasi terdeteksi oleh sistem?
- Apakah identity berubah?
- Apakah closure menangkap snapshot lama?
- Apakah memo/cache dependency list lengkap?
- Apakah update terjadi di luar framework boundary?
19. Infinite Loop dan Cascading Update
Infinite loop muncul ketika effect menulis state yang dibacanya sendiri.
const [count, setCount] = createSignal(0);
createEffect(() => {
setCount(count() + 1);
});
Ini jelas salah. Yang lebih halus:
createEffect(() => {
const normalized = normalize(formValue());
setFormValue(normalized);
});
Kalau normalize selalu membuat object baru, effect bisa loop.
Better:
function setNormalizedFormValue(next: FormValue) {
setFormValue(prev => {
const normalized = normalize(next);
return deepEqual(prev, normalized) ? prev : normalized;
});
}
Atau jadikan normalization sebagai bagian dari reducer, bukan effect.
20. Reactive Boundary Design
Semakin besar aplikasi, semakin penting boundary.
Boundary yang perlu dipisahkan:
| Boundary | Contoh |
|---|---|
| UI local state | dropdown open, active tab, focused row |
| Domain state | selected case, current workflow transition |
| Server cache | query result, mutation result, freshness |
| URL state | filters, pagination, route params |
| Session state | feature flag, tenant, user role |
| External state | WebSocket, BroadcastChannel, storage event |
| Imperative state | third-party widget, map instance, editor instance |
Jangan masukkan semua ke satu global reactive store.
Global store yang terlalu besar menjadi:
- sulit di-test;
- sulit di-code split;
- sulit di-reset;
- rawan unauthorized data retention;
- rawan cross-feature coupling;
- sulit memetakan ownership.
Better:
feature boundary owns feature state
server cache owns remote data
router owns URL state
component owns ephemeral interaction state
domain service owns workflow rules
21. Debugging Reactive Systems
Saat UI update tidak sesuai harapan, gunakan alur diagnosis berikut.
21.1 Jika UI Tidak Update
Checklist:
- Apakah source state berubah?
- Apakah mutation dilakukan melalui API reactive yang benar?
- Apakah dependency dibaca dalam reactive scope?
- Apakah equality menganggap nilai tidak berubah?
- Apakah derived value di-cache dengan dependency salah?
- Apakah component memoized terlalu agresif?
- Apakah update terjadi di luar framework boundary?
- Apakah async result lama menimpa result baru?
21.2 Jika UI Terlalu Sering Update
Checklist:
- Apakah source state terlalu luas?
- Apakah object/function identity berubah setiap render?
- Apakah context/store menyebabkan semua consumer update?
- Apakah derived value mahal tidak dimemo?
- Apakah effect menulis state saat render/update?
- Apakah subscription dibuat ulang tanpa cleanup?
- Apakah event handler memicu state pada frekuensi tinggi?
- Apakah batching tidak aktif?
21.3 Jika UI Kadang Salah
Checklist:
- Apakah ada race async?
- Apakah ada stale closure?
- Apakah ada optimistic update tanpa rollback?
- Apakah ada partial state update yang melanggar invariant?
- Apakah ada external event yang datang di urutan tidak terduga?
- Apakah ada local cache yang tidak invalid?
- Apakah hydration/server snapshot berbeda dari client?
22. Production Invariants
Gunakan invariant ini saat desain review:
- Setiap derived value punya dependency jelas.
- Derived value tidak melakukan effect.
- Effect punya cleanup bila membuka resource.
- Effect async punya cancellation atau stale-result guard.
- Source state tidak menduplikasi derived state.
- Global state punya ownership dan reset policy.
- Server state punya freshness dan error model.
- State transition yang kompleks dimodelkan sebagai union/state machine.
- Update yang harus atomic tidak dipecah menjadi flags terpisah.
- Reactive graph tidak punya cycle tanpa guard eksplisit.
- UI tidak bergantung pada urutan timing yang tidak dijamin.
- Debug path dari mutation ke render bisa dijelaskan.
23. Decision Matrix
| Masalah | Model yang Cocok | Hindari |
|---|---|---|
| Derived value murah | Function/pull derivation | Stored duplicated state |
| Derived value mahal | Memo/computed | Recompute di banyak tempat |
| User input cepat | Debounced reactive effect | Fire request per keystroke |
| Realtime stream | Observable/event stream | Polling tanpa model |
| Workflow state | State machine/reducer | Boolean flags lepas |
| Server data | Query cache/resource abstraction | Local signal biasa tanpa freshness |
| Local UI toggle | Component local state | Global store |
| Cross-feature session | Scoped global store/context | Prop drilling ekstrem |
| Third-party widget | Imperative boundary with cleanup | Direct mutation tersebar |
| Animation | Frame scheduler | Microtask-heavy visual update |
24. Practice Loop 1 — Build Reactive Core
Latihan:
- Implement
createSignal. - Implement
createEffect. - Tambahkan dependency cleanup.
- Tambahkan nested effect stack.
- Tambahkan
createMemo. - Tambahkan batching.
- Buat diamond dependency test.
- Buat infinite loop detection sederhana.
Acceptance criteria:
- effect hanya rerun bila dependency aktual berubah;
- dependency conditional dibersihkan;
- nested effect tidak salah subscribe;
- memo tidak recompute bila tidak stale;
- batch hanya flush sekali;
- diamond graph tidak glitch.
25. Practice Loop 2 — Search UI dengan Race Control
Bangun search box dengan requirement:
- minimal 2 karakter;
- debounce 300ms;
- request lama dibatalkan atau diabaikan;
- loading state tidak flicker berlebihan;
- error dapat diretry;
- result cache per query;
- clear input membatalkan request;
- test untuk out-of-order response.
State model:
type SearchState =
| { tag: "idle" }
| { tag: "debouncing"; query: string }
| { tag: "loading"; query: string; previous?: SearchResult[] }
| { tag: "success"; query: string; results: SearchResult[]; cached: boolean }
| { tag: "error"; query: string; error: Error; previous?: SearchResult[] };
Learning goal:
Bisa membedakan reactivity biasa dari time-aware reactivity.
26. Practice Loop 3 — Workflow UI
Buat UI workflow case management:
Draft -> Submitted -> Under Review -> Escalated -> Resolved -> Closed
Requirement:
- role menentukan transition yang tersedia;
- status menentukan action button;
- transition tertentu butuh confirmation;
- transition tertentu butuh reason;
- optimistic update boleh, tetapi harus rollback bila gagal;
- URL menyimpan selected case dan active tab;
- server cache invalid setelah transition sukses.
Goal:
- source state minimal;
- derived actions pure;
- mutation effect terisolasi;
- rollback eksplisit;
- tidak ada boolean flags inkonsisten.
27. Anti-Patterns
27.1 Syncing State with Effects
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Jika full name bisa dihitung saat render, tidak perlu effect.
27.2 Global Store as Dumping Ground
const globalStore = {
currentUser: null,
modalOpen: false,
searchInput: "",
checkoutStep: 1,
temporaryTooltipPosition: null,
adminTableHoveredRowId: null
};
Ini mencampur session, local UI, workflow, dan ephemeral state.
27.3 Watcher Chains
watch A -> set B
watch B -> set C
watch C -> fetch D
watch D -> set E
Lebih baik desain sebagai reducer/resource pipeline.
27.4 Hidden Effect in Getter
function getCurrentUser() {
analytics.track("read_current_user");
return store.user;
}
Getter harus pure.
27.5 Memoization as Band-Aid
Memoization tidak memperbaiki state boundary yang buruk. Ia hanya mengurangi gejala.
28. Architecture Review Checklist
Gunakan checklist ini untuk pull request besar:
[ ] Source state minimal dan jelas owner-nya.
[ ] Derived state tidak disimpan tanpa alasan kuat.
[ ] Effect hanya berada di boundary.
[ ] Async effect punya cancellation/stale guard.
[ ] Tidak ada watcher chain yang menggantikan model domain.
[ ] Tidak ada global store untuk ephemeral component state.
[ ] Server state punya cache/freshness/error/retry model.
[ ] Invariant state kompleks dimodelkan dengan union/state machine.
[ ] Dependency graph bisa dijelaskan oleh author PR.
[ ] Performance impact update path sudah dipikirkan.
[ ] Cleanup subscription/timer/observer/socket tersedia.
[ ] Test mencakup race condition dan rapid interaction.
29. Common Failure Modes
| Failure Mode | Gejala | Penyebab Umum | Perbaikan |
|---|---|---|---|
| Stale closure | Handler membaca nilai lama | Closure menangkap snapshot lama | Gunakan reactive read/ref/updater |
| Effect loop | CPU tinggi, UI freeze | Effect menulis dependency sendiri | Pindahkan ke reducer atau guard equality |
| Glitch | Derived value sementara salah | Propagation order buruk | Batch/topological/lazy recompute |
| Over-render | Component sering render | State terlalu luas atau identity tidak stabil | Narrow state, memo tepat, split boundary |
| Under-update | UI tidak berubah | Dependency tidak tracked | Pastikan read terjadi di reactive scope |
| Memory leak | Heap naik terus | Subscription/timer tidak cleanup | Lifecycle cleanup |
| Race overwrite | Data lama menimpa data baru | Async tanpa cancellation/versioning | AbortController/request id |
| Cache drift | UI beda dari server | Invalidasi tidak jelas | Query cache policy |
| State drift | Dua state tidak sinkron | Derived state disimpan | Single source + computed |
30. Ringkasan
Reactivity bukan magic framework. Reactivity adalah disiplin untuk menjaga graph perubahan tetap benar.
Prinsip utama:
- Modelkan dependency sebelum memilih API.
- Simpan source state seminimal mungkin.
- Hitung derived state, jangan sinkronkan manual.
- Letakkan effect di edge.
- Semua async effect perlu cancellation atau stale guard.
- Scheduler memengaruhi correctness, bukan hanya performance.
- Over-reactivity sama bahayanya dengan under-reactivity.
- Server state butuh model freshness, bukan sekadar signal.
- Workflow kompleks lebih aman dengan state machine.
- Reactive system yang baik dapat dijelaskan sebagai graph.
Part berikutnya akan membahas component architecture at scale: bagaimana membangun component contract, ownership boundary, headless primitives, controlled/uncontrolled patterns, dan design-system-grade API agar reactive state tidak berubah menjadi spaghetti antar component.
31. Referensi
- MDN Web Docs — Microtask guide: https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
- Vue.js Guide — Reactivity Fundamentals: https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- Vue.js API — Reactivity Core: https://vuejs.org/api/reactivity-core
- SolidJS Docs — Signals: https://docs.solidjs.com/concepts/signals
- SolidJS Docs — Intro to Reactivity: https://docs.solidjs.com/concepts/intro-to-reactivity
- React Docs — Managing State: https://react.dev/learn/managing-state
- React Docs — Sharing State Between Components: https://react.dev/learn/sharing-state-between-components
You just completed lesson 11 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.