Basics

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

Illustration: every letter to the vault passes through the doorman's booth first

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.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →