What is Reactive Cleanup?
Quick Start (30 seconds)
// Create an effect and get a dispose function
const dispose = effect(() => {
console.log('Count:', state.count);
});
// Later, when you no longer need this effect:
dispose();
// Now state.count changes won't trigger the effect anymore
state.count = 99; // Nothing loggedThat's the core idea — every effect returns a dispose function that stops it from running.
What is Reactive Cleanup?
Reactive Cleanup is a system that patches the DOMHelpers reactive library to provide proper lifecycle management for effects, watchers, and computed properties. It ensures that when you're done with a reactive effect, you can fully dispose of it — removing it from all dependency tracking so it never runs again.
Simply put, it's the "off switch" for reactive effects. Without it, effects could keep running even after you no longer need them.
Why Does This Exist?
The Challenge Without Cleanup
Imagine you have a dashboard with multiple panels. Each panel creates reactive effects to update the UI:
function createPanel(data) {
const state = state({ items: data });
// This effect renders the panel
effect(() => {
Elements.panel.update({
innerHTML: state.items.map(i => `<div>${i.name}</div>`).join('')
});
});
// This effect tracks the count
effect(() => {
Elements.count.update({ textContent: state.items.length });
});
}Now the user navigates away, and you remove the panel from the DOM. The HTML is gone — but the effects are still alive. Every time state.items changes, those effects try to update DOM elements that no longer exist.
What's the real issue?
Without cleanup:
Panel created → Effects registered
↓
Panel removed from DOM
↓
Effects still alive in memory
↓
state.items changes → Effects run → Try to update removed DOM
↓
❌ Wasted CPU cycles
❌ Potential errors (accessing removed elements)
❌ Memory held by closures that will never be freed
❌ Over time, hundreds of "zombie" effects accumulateThe Cleanup Approach
function createPanel(data) {
const state = state({ items: data });
const dispose1 = effect(() => {
Elements.panel.update({
innerHTML: state.items.map(i => `<div>${i.name}</div>`).join('')
});
});
const dispose2 = effect(() => {
Elements.count.update({ textContent: state.items.length });
});
// Return a cleanup function
return function destroyPanel() {
dispose1(); // Stop the render effect
dispose2(); // Stop the count effect
};
}
const destroyPanel = createPanel(myData);
// Later, when removing the panel:
destroyPanel(); // Both effects are fully disposedWhat happens:
With cleanup:
Panel created → Effects registered → dispose functions returned
↓
Panel removed from DOM → dispose() called
↓
Effects unregistered from all state dependencies
↓
state.items changes → Nothing happens ✅
↓
✅ No wasted CPU
✅ No errors
✅ Memory can be garbage collected
✅ Clean, predictable lifecycleMental Model
Think of reactive effects as magazine subscriptions.
When you subscribe (create an effect), you receive updates every time a new issue comes out (state changes). Without cleanup, cancelling your subscription means throwing the magazines in the bin — but the publisher keeps sending them. They pile up at your door, wasting resources.
The Cleanup system is the proper cancellation process. When you call dispose(), you tell the publisher (the state) to stop sending updates to you (the effect). No more deliveries. No more pile-up.
What Does the Cleanup System Add?
The cleanup module patches the existing reactive library. After it loads, here's what changes:
Before cleanup loads: After cleanup loads:
────────────────────── ──────────────────────
effect(() => { ... }) effect(() => { ... })
→ returns undefined → returns dispose()
watch(state, key, fn) watch(state, key, fn)
→ returns undefined → returns dispose()
No cleanup method state.cleanup()
→ disposes ALL effects for this state
No collector utility ReactiveCleanup.collector()
→ groups multiple dispose functions
No scope utility ReactiveCleanup.scope(fn)
→ auto-collects disposals
component.destroy() component.destroy()
→ basic teardown → teardown + cleanup()
reactive().build().destroy() reactive().build().destroy()
→ basic teardown → teardown + cleanup()How It Works (High Level)
The cleanup system introduces two registries using WeakMap:
┌─────────────────────────────────────────────────┐
│ effectRegistry (WeakMap) │
│ Maps each effect → { states, disposed } │
│ Tracks which states/keys each effect depends on│
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ stateRegistry (WeakMap) │
│ Maps each state → { effects } │
│ Tracks which effects are watching each key │
└─────────────────────────────────────────────────┘When an effect is created:
1️⃣ Effect runs → accesses state.count
↓
2️⃣ effectRegistry records: "this effect depends on state.count"
↓
3️⃣ stateRegistry records: "state.count is watched by this effect"When dispose() is called:
1️⃣ Effect marked as disposed
↓
2️⃣ effectRegistry: removes all state/key associations
↓
3️⃣ stateRegistry: removes this effect from all state keys
↓
4️⃣ Effect will never run againWhy WeakMap? Because WeakMaps allow garbage collection. When a state or effect object is no longer referenced anywhere else in your code, the WeakMap entry is automatically cleaned up by the browser. No manual memory management needed.
Where Does It Come From?
Available on these globals:
ReactiveCleanup // The cleanup API object
ReactiveCleanup.collector() // Create a cleanup collector
ReactiveCleanup.scope(fn) // Create a cleanup scope
ReactiveCleanup.patchState() // Patch an existing state
ReactiveCleanup.isActive() // Check if an effect is active
ReactiveCleanup.getStats() // Get system stats
ReactiveCleanup.debug() // Enable debug mode
ReactiveCleanup.test() // Run the built-in test
// Also on ReactiveUtils:
ReactiveUtils.cleanup // Same as ReactiveCleanup
collector() // Same as ReactiveCleanup.collector()
scope(fn) // Same as ReactiveCleanup.scope()Patched methods (transparent — no API change):
effect(fn) // Now returns dispose()
state(obj) // Now adds cleanup()
watch(state, key, fn) // Now returns dispose()
computed(state, { key: fn }) // Now tracked for cleanup
component.destroy() // Now calls cleanup() automaticallyBig Picture: How Cleanup Fits In
DOMHelpers Reactive System
│
├── reactive module → Core reactive state & effects
├── 02_dh-reactive-array-patch → Array mutation reactivity
├── 03_dh-reactive-collections → Collection management
├── 04_dh-reactive-form → Form state management
├── 05_dh-reactive-cleanup → Lifecycle & memory management ← YOU ARE HEREThe cleanup module sits on top of the reactive core. It doesn't replace the reactive system — it enhances it with proper disposal capabilities for production use.
Key Takeaways
- Every
effect()now returns adisposefunction that stops the effect from running state.cleanup()disposes all effects associated with a state- WeakMaps are used internally so garbage collection works naturally
- Components and builders automatically call
cleanup()on destroy - The cleanup system is transparent — existing code works without changes, but now you can properly clean up when needed
- No zombie effects — disposed effects never run again, even if their dependencies change
What's next?
Let's walk through effect disposal and state cleanup step by step with detailed examples.
Let's continue!