What is a Proxy in JavaScript — and why the console says Proxy(Object)

You're building a Vue app. You write state.count++ — and the number on screen updates. You didn't call any setState or ask for a re-render. You just changed a field on an object.
Then you write console.log(state) and see something odd:
Proxy(Object) { count: 1 }
Not { count: 1 }, but some Proxy. That's the whole trick. It's not your object — it's its doorman.
What a Proxy is
A Proxy is a middleman object that stands in front of a real object and intercepts everything done to it: reading a field, writing, deleting, checking with in.
You talk to the proxy — and it decides what to answer. It can pass things through untouched. It can log them. It can refuse.
Same idea as a network proxy server: a middleman between you and whoever you're reaching. Only here it all happens inside one program.
How it works
You create one with new Proxy(target, handler). The target is a plain object. The handler is an object of "traps" — functions for each action.
const user = { name: "Anna" };
const watched = new Proxy(user, {
get(target, key) {
console.log("reading", key);
return target[key];
},
set(target, key, value) {
console.log("writing", key, "=", value);
target[key] = value;
return true;
},
});
watched.name; // reading name
watched.age = 20; // writing age = 20
Code that uses watched doesn't even know about the middleman. To it, it's just an object. But every touch goes through your functions.
There are over a dozen traps: get, set, has, deleteProperty, ownKeys and more. The first two are enough to start.
Where you already use it
Vue 3 reactivity. reactive() returns a Proxy. The get trap records "this component read count". The set trap says "count changed — re-render everyone who read it". Hence the magic of state.count++.
Vue 2 didn't use Proxy, and it hurt. A field added after the object was created simply went unnoticed. You had to write Vue.set(obj, "key", value). Proxy closed that gap: it catches even fields that didn't exist yet.
Immer — the library that lets you write immutable updates as ordinary mutations. Inside produce you change a draft proxy; Immer records the changes and builds a new object.
Validation. A set trap can check the value and throw: "age must be a number". Handy for configs and debugging.
The takeaway: if state updates the screen "by itself", there's almost certainly a Proxy nearby. Compare with state in React, where the same effect needs an explicit setState.
Build mini-reactivity in 10 lines
To make Vue's magic stop being magic, build a stripped-down version yourself:
function reactive(obj, onChange) {
return new Proxy(obj, {
set(target, key, value) {
target[key] = value;
onChange(key, value);
return true;
},
});
}
const state = reactive({ count: 0 }, () => {
document.querySelector("#count").textContent = state.count;
});
state.count++; // the number on the page updates by itself
That's the whole principle. You change a field — the set trap notices and updates the screen. Real Vue is smarter: it remembers who read which field and re-renders only them. But the skeleton is the same.
Three traps where it breaks
1. The proxy isn't the original. reactive(obj) === obj is false. Compare objects with === or put them in a Set — you get two different objects. Vue has toRaw() for this.
2. Destructuring cuts the wire. const { count } = reactive(state) — and count is now just a number. A number has no middleman, nothing to intercept. The screen stops updating. Vue's docs warn about exactly this.
3. Map, Set and private fields. Try:
const p = new Proxy(new Map(), {});
p.set("a", 1); // TypeError: ... called on incompatible receiver
Map methods look for internal data on the Map itself, and they got a proxy instead. Private class fields #x have the same problem. Vue and Immer work around it with special code; a hand-rolled Proxy doesn't.
Bonus: structuredClone(proxy) throws DataCloneError. A proxy can't be cloned. Get the raw object first.
Is Proxy slow?
Slower than direct access: every touch is a function call. For UI state it's unnoticeable. In a hot loop of millions of iterations, work with the raw object.
How is a Proxy different from a getter and setter?
A getter get name() sits on one field known in advance. A Proxy intercepts any field, even one that doesn't exist yet. That's why Vue 3 switched to it.
Can I get the original object out of a Proxy?
There's no built-in way — on purpose. Libraries provide their own: toRaw() in Vue, original() and current() in Immer.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.





