Creating Reactive State and the Proxy System
Let's understand how reactive state works under the hood — the Proxy system, dependency tracking, deep reactivity, and batching.
How state() works
When you call state(), here's what happens:
state({ count: 0, name: 'Alice' })
↓
1️⃣ Check: is the input an object?
├── Not an object → return as-is (no proxy)
└── Is an object → continue
↓
2️⃣ Check: is it already reactive?
├── Already reactive → return as-is (no double-wrapping)
└── Not reactive → continue
↓
3️⃣ Check: is it a built-in type to skip?
├── Date, RegExp, Promise, etc. → return as-is
├── DOM Node or Element → return as-is
└── Plain object or array → continue
↓
4️⃣ Create a Proxy around the object
├── get trap → tracks reads (dependency collection)
└── set trap → triggers updates (change notification)
↓
5️⃣ Add instance methods (computed, watch, batch, etc.)
↓
6️⃣ Return the proxyThe Proxy — what it is
A JavaScript Proxy is a wrapper around an object that lets you intercept operations like reading and writing properties.
const original = { count: 0 };
const proxy = new Proxy(original, {
get(obj, key) {
console.log(`Someone read "${key}"`);
return obj[key];
},
set(obj, key, value) {
console.log(`Someone set "${key}" to ${value}`);
obj[key] = value;
return true;
}
});
proxy.count; // Console: Someone read "count"
proxy.count = 5; // Console: Someone set "count" to 5The reactive system uses this mechanism to detect reads (for dependency tracking) and detect writes (for triggering updates).
The get trap — dependency tracking
When you read a property from a reactive object inside an effect, the system records: "This effect depends on this property."
const app = state({ count: 0 });
effect(() => {
console.log(app.count); // get trap fires → dependency recorded
});What happens inside the get trap
app.count is read
↓
1️⃣ Is there a currently running effect?
├── No → just return the value (no tracking)
└── Yes → continue
↓
2️⃣ Create a dependency entry for "count" if it doesn't exist
↓
3️⃣ Add the current effect to the dependency set for "count"
↓
4️⃣ Return the valueThink of it as a sign-up sheet:
Property "count" — who's watching:
├── Effect 1 (the console.log effect)
├── Effect 3 (some other effect)
└── Effect 7 (another effect)
Property "name" — who's watching:
├── Effect 2
└── Effect 5Each property keeps its own list of effects that depend on it.
The set trap — triggering updates
When you write to a property, the system notifies all effects that depend on it.
app.count = 5; // set trap fires → effects notifiedWhat happens inside the set trap
app.count = 5
↓
1️⃣ Is the new value the same as the old value?
├── Yes → do nothing (no unnecessary updates)
└── No → continue
↓
2️⃣ Store the new value on the original object
↓
3️⃣ Check: do any computed properties depend on "count"?
├── Yes → mark them as dirty (need recalculation)
└── No → skip
↓
4️⃣ Look up the dependency set for "count"
↓
5️⃣ Queue each effect for re-execution
↓
6️⃣ If not batching, run all queued effects nowSame-value optimization
app.count = 5;
app.count = 5; // No effect re-runs — value didn't actually changeThe set trap checks if (obj[key] === value) return true; before doing anything. This prevents unnecessary re-renders.
Deep reactivity
Nested objects are automatically made reactive when you access them:
const app = state({
user: {
profile: {
name: 'Alice'
}
}
});
// When you access app.user, the system wraps it in a Proxy too
// When you access app.user.profile, that gets wrapped too
// All the way down the chainHow deep reactivity works
app.user.profile.name = 'Bob'
↓
app.user → get trap fires → returns reactive proxy of user
↓
.profile → get trap fires → returns reactive proxy of profile
↓
.name = 'Bob' → set trap fires → triggers effectsEach nested object gets its own Proxy the first time it's accessed. This is called lazy deep reactivity — objects are only wrapped when actually needed.
Objects that are NOT made deeply reactive
const app = state({
createdAt: new Date(), // Skipped — Date is a built-in
controller: new AbortController(), // Skipped — AbortController
pattern: /test/i, // Skipped — RegExp
element: document.body // Skipped — DOM element
});
// These are stored as-is inside the reactive object
// The PROPERTY is tracked, but the object itself isn't proxied
app.createdAt = new Date(); // Triggers effects (property change)
app.createdAt.setHours(12); // Does NOT trigger effects (Date mutation)Batching — grouping updates
When you change multiple properties, each change would normally trigger its effects immediately. Batching groups them into a single flush:
const app = state({ firstName: 'Alice', lastName: 'Smith' });
effect(() => {
console.log(`${app.firstName} ${app.lastName}`);
});
// Without batching:
app.firstName = 'Bob'; // Effect runs: "Bob Smith"
app.lastName = 'Jones'; // Effect runs: "Bob Jones"
// Two runs — intermediate state visible
// With batching:
batch(() => {
app.firstName = 'Bob'; // Queued, not run yet
app.lastName = 'Jones'; // Queued, not run yet
});
// Effect runs once: "Bob Jones"
// One run — only final state visibleHow batching works
batch(() => { ... })
↓
1️⃣ Increment batchDepth (now > 0)
↓
2️⃣ Run the function
├── app.firstName = 'Bob' → effect queued (not run)
└── app.lastName = 'Jones' → effect queued (not run)
↓
3️⃣ Decrement batchDepth (back to 0)
↓
4️⃣ Flush: run all queued effects once
└── Effect runs with final state: "Bob Jones"Nested batching
Batches can be nested. Effects only flush when the outermost batch completes:
batch(() => {
app.a = 1;
batch(() => {
app.b = 2;
app.c = 3;
});
// Inner batch finished, but outer batch still active
// Effects NOT flushed yet
app.d = 4;
});
// NOW effects flush — all four changes processed at onceThe toRaw() utility
Sometimes you need the original, unwrapped object:
const app = state({ count: 0 });
// Through the state object
const raw = toRaw(app);
console.log(raw); // { count: 0 } — the original plain object
// Through the utility function
const raw2 = toRaw(app);
console.log(raw2); // { count: 0 }When to use toRaw()
// Sending to an API — don't send the Proxy
fetch('/api/save', {
method: 'POST',
body: JSON.stringify(toRaw(app)) // Send the plain object
});
// Comparing objects
if (toRaw(stateA) === toRaw(stateB)) {
console.log('Same underlying object');
}The isReactive() utility
Check whether an object is reactive:
const app = state({ count: 0 });
const plain = { count: 0 };
console.log(isReactive(app)); // true
console.log(isReactive(plain)); // falsePause and resume
For advanced scenarios, you can manually pause and resume reactivity:
// Pause — changes won't trigger effects
pause();
app.a = 1;
app.b = 2;
app.c = 3;
// No effects run during pause
// Resume — flush pending updates
resume(true); // true = flush now
// All queued effects run at onceUntrack — reading without tracking
Sometimes you want to read a reactive property without creating a dependency:
effect(() => {
// This read IS tracked — effect will re-run when name changes
const name = app.name;
// This read is NOT tracked — effect won't re-run when count changes
const count = untrack(() => app.count);
console.log(`${name}: ${count}`);
});
app.name = 'Bob'; // Effect re-runs (name is tracked)
app.count = 99; // Effect does NOT re-run (count was untracked)Key takeaways
- state() wraps a plain object in a Proxy that tracks reads and writes
- get trap records which effects depend on which properties
- set trap notifies all dependent effects when a property changes
- Deep reactivity — nested objects are automatically made reactive
- Same-value check — changing a property to the same value does nothing
- Built-in types (Date, RegExp, DOM nodes) are skipped and stored as-is
- Batching groups multiple changes into a single flush
- toRaw() gives you the original unwrapped object
- isReactive() checks if an object is reactive
- untrack() reads a property without creating a dependency
What's next?
Now that you understand how the Proxy system works, let's explore:
- Effects, computed properties, and watchers in depth
- Instance methods for advanced state control
- Specialized state factories
Let's continue!