JavaScript Proxy and Reflect: Meta-Programming, Reactivity, and How Vue and MobX Work

Link copied
JavaScript Proxy and Reflect: Meta-Programming, Reactivity, and How Vue and MobX Work

JavaScript Proxy and Reflect: Meta-Programming, Reactivity, and How Vue and MobX Work

JS Tutorial Module 10: Advanced Lesson 10.1

Proxy and Reflect are JavaScript's meta-programming primitives. A Proxy wraps an object and intercepts every operation on it — property reads, writes, deletes, function calls. Reflect is the helper namespace that performs the underlying operations a Proxy might intercept. Together, they're how frameworks like Vue, MobX, and Immer make plain objects "reactive" or "immutable" without any explicit setter API.

This lesson covers what a Proxy actually does, the trap methods you'll use 90% of the time, the role of Reflect, and practical patterns including validation, logging, and a working reactivity skeleton.

What a Proxy is #

A Proxy is an object that wraps another object (the target) and intercepts operations on it using a handler — a set of callback functions called "traps."

const target = { name: 'Ada' };
const handler = {
  get(obj, prop) {
    console.log(`reading ${prop}`);
    return obj[prop];
  },
};

const proxy = new Proxy(target, handler);
proxy.name;          // logs 'reading name', returns 'Ada'
proxy.unknown;       // logs 'reading unknown', returns undefined

The Proxy looks and behaves like the target — but every property access goes through the trap first.

The trap methods #

A handler can implement up to 13 traps. The five most-used:

Trap Triggers on Common use
get(target, prop, receiver) reading a property Logging, reactivity, default values
set(target, prop, value, receiver) writing a property Validation, change notifications
has(target, prop) prop in target Hide certain keys
deleteProperty(target, prop) delete target.prop Prevent deletion
ownKeys(target) Object.keys(target), spread, etc. Filter visible keys

The remaining traps handle apply (function calls on the proxy), construct (new ProxyOfFunction()), and prototype operations. Useful for wrapping functions and exotic cases.

A logging proxy #

The "hello world" of Proxy:

function logged(target, name = 'obj') {
  return new Proxy(target, {
    get(obj, prop) {
      console.log(`${name}.${String(prop)} read`);
      return obj[prop];
    },
    set(obj, prop, value) {
      console.log(`${name}.${String(prop)} = ${JSON.stringify(value)}`);
      obj[prop] = value;
      return true;  // signals successful set
    },
  });
}

const user = logged({ name: 'Ada', age: 36 }, 'user');
user.name;        // logs 'user.name read'
user.age = 37;    // logs 'user.age = 37'

Note: set MUST return true if the set succeeded, or false (which throws in strict mode). Forgetting the return is a common bug.

A validation proxy #

Enforce shape rules at runtime:

function validated(schema) {
  return new Proxy({}, {
    set(obj, prop, value) {
      const check = schema[prop];
      if (!check) {
        throw new Error(`Unknown property: ${String(prop)}`);
      }
      if (typeof value !== check) {
        throw new TypeError(`${String(prop)} must be ${check}, got ${typeof value}`);
      }
      obj[prop] = value;
      return true;
    },
  });
}

const user = validated({ name: 'string', age: 'number' });
user.name = 'Ada';   // ok
user.age = 36;        // ok
user.email = 'x';     // throws — unknown property
user.age = 'old';     // throws — wrong type

Lightweight runtime types. For real apps, prefer a schema library (zod, valibot) — they generate TypeScript types from the schema and validate without the Proxy overhead.

A default-values proxy #

function withDefaults(target, defaults) {
  return new Proxy(target, {
    get(obj, prop) {
      return prop in obj ? obj[prop] : defaults[prop];
    },
  });
}

const config = withDefaults({ port: 8080 }, { host: 'localhost', port: 3000 });
config.port;  // 8080 (own)
config.host;  // 'localhost' (default)

Falls back without modifying the target.

How reactive frameworks work #

The principle behind Vue 3, MobX 6+, and similar libraries:

let currentEffect = null;
const subscribers = new WeakMap();

function reactive(obj) {
  return new Proxy(obj, {
    get(target, prop) {
      // track: remember that the current effect depends on this property
      if (currentEffect) {
        let map = subscribers.get(target);
        if (!map) subscribers.set(target, (map = new Map()));
        let set = map.get(prop);
        if (!set) map.set(prop, (set = new Set()));
        set.add(currentEffect);
      }
      return target[prop];
    },
    set(target, prop, value) {
      target[prop] = value;
      // notify: re-run any effects that depended on this property
      const subs = subscribers.get(target)?.get(prop);
      subs?.forEach(fn => fn());
      return true;
    },
  });
}

function effect(fn) {
  currentEffect = fn;
  fn();
  currentEffect = null;
}

const state = reactive({ count: 0 });
effect(() => console.log('Count is', state.count));
// logs 'Count is 0'

state.count = 5;
// logs 'Count is 5' — the effect re-ran automatically

state.count = 10;
// logs 'Count is 10' — again

That's 30 lines, and it's a working reactivity system. Real libraries add: nested object handling, array operation hooks, computed properties, deduplication of effects within a tick, and lots more. But the kernel is two traps and a subscription table.

This is why Proxy matters even if you'll never write one yourself: it's the foundation of how the modern reactive frameworks you use every day actually work.

Reflect — the helper namespace #

Reflect is a built-in object with methods that mirror the operations Proxy intercepts:

Reflect.get(obj, 'name');         // same as obj.name
Reflect.set(obj, 'name', 'Ada');  // same as obj.name = 'Ada', returns true/false
Reflect.has(obj, 'name');         // same as 'name' in obj
Reflect.deleteProperty(obj, 'name'); // same as delete obj.name
Reflect.ownKeys(obj);             // Object.getOwnPropertyNames + Symbols
Reflect.construct(Cls, args);     // same as new Cls(...args)
Reflect.apply(fn, thisArg, args); // same as fn.apply(thisArg, args)

Three reasons Reflect exists:

  1. Function-shape replacements for operators. Reflect.set(obj, key, val) is a function you can pass around; obj[key] = val isn't.
  2. Return values instead of side-effects. Reflect.set returns true/false; the operator throws. Often easier to handle in a Proxy trap.
  3. Sane signature for new. Reflect.construct(Cls, args, newTarget) lets you specify all three pieces — useful in advanced inheritance patterns.

Reflect inside a Proxy trap #

A common idiom: forward the default behavior using Reflect, then add your own logic:

const handler = {
  get(target, prop, receiver) {
    console.log(`get ${String(prop)}`);
    return Reflect.get(target, prop, receiver);  // default behavior
  },
  set(target, prop, value, receiver) {
    if (typeof value === 'string') value = value.trim();
    return Reflect.set(target, prop, value, receiver);
  },
};

Using Reflect.get(target, prop, receiver) instead of target[prop] preserves the receiver argument — important when the Proxy is the receiver in a chain of accesses (matters for getters defined on prototypes).

For simple traps, target[prop] is fine. For library code that needs to be "transparent," always forward via Reflect.x with the receiver passed through.

Limitations #

Proxies can't be detected #

typeof proxy === 'object'. There's no isProxy API. By design — the whole point is transparency.

Some operations bypass traps #

  • Internal slots (Maps, Sets, Dates' internals) can't be proxied transparently. new Map() wrapped in a Proxy may throw on map.get('x') because the Map method expects to find its internal slot on this.
  • String(proxy) and +proxy may or may not hit traps depending on how toPrimitive resolves.

Performance cost #

Every property access goes through a JS function call. For hot loops, raw object access is faster. For app-level state that's read in render functions a few hundred times per render, the cost is usually negligible — Vue and MobX both ship in performance-conscious apps.

Revoke #

A proxy can be permanently disabled:

const { proxy, revoke } = Proxy.revocable({}, {});
proxy.x = 1;       // ok
revoke();
proxy.x;           // TypeError — proxy has been revoked

Useful for sandboxing — give code access to a proxy, revoke it after, the code can no longer reach the target.

When to actually use Proxy #

Day-to-day app code rarely needs Proxy directly. The cases where it earns its keep:

  • Building a reactive framework or state container. Vue, MobX, Immer, valtio, signals-with-proxy.
  • Building a runtime validation layer. Though zod and friends are usually a better answer.
  • Building an ORM/DSL. Pretending DB rows are JS objects with magic accessors.
  • Mocking and testing. Recording all calls on a fake object.
  • Building developer tools. Logging, debugging wrappers.

If you're not building infrastructure, you'll mostly read Proxy code in library internals rather than write it.

A summary #

  • Proxy wraps an object and intercepts operations with handler traps.
  • The 5 most-used traps: get, set, has, deleteProperty, ownKeys.
  • set must return true/false. Forgetting is the #1 bug.
  • Reflect mirrors the default operations as functions. Use it inside traps to forward unchanged behavior.
  • Reactivity frameworks (Vue, MobX, Immer) are essentially Proxy + a subscription table.
  • Proxies are transparent — no isProxy detection, by design.
  • Day-to-day app code rarely uses Proxy directly; you'll mostly meet it through libraries.

What's next #

Lesson 10.2 covers async iterationfor await...of, the AsyncIterable protocol, async generators, and the patterns for processing streams of data one chunk at a time.

Try it yourself #

The smallest useful Proxy — a logger — is the easiest way to feel the trap mechanism:

YouPredict the logs:
const x = new Proxy({}, {
get(_, p) { console.log('GET', p); return 1; },
set(t, p, v) { console.log('SET', p, v); t[p] = v; return true; }
});
x.a;
x.b = 5;
x.a + x.a;
Claude · used js_sandboxOutput:
GET a
SET b 5
GET a
GET a

Every property access — read or write — hits a trap. Even the simple expression x.a + x.a triggers two gets. This is what makes reactive frameworks tick: every access becomes a dependency-tracking event.

Proxy is the lens through which every modern reactive library's internals make sense.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *