Vue 3 uses it for reactivity. MobX uses it for observable state. Immer uses it for immutable updates. The Proxy API is one of the most powerful features ever added to JavaScript — yet most developers have never written one from scratch.
If you've ever wanted to intercept property access, validate object mutations, log every get/set operation, or build a reactive system without compile-time magic, Proxies are the answer.
Let's break them down — from the basics to production-grade patterns.
What is a Proxy?
A Proxy is an object that wraps another object (the "target") and lets you intercept fundamental operations on it — reading properties, writing properties, deleting properties, checking if a property exists, and more.
Think of it as middleware for objects. Every operation passes through your handler before reaching (or not reaching) the underlying data.
const user = { name: 'Divyam', role: 'admin' };
const proxiedUser = new Proxy(user, {
get(target, prop) {
console.log(`Reading ${String(prop)}`);
return target[prop];
},
set(target, prop, value) {
console.log(`Writing ${String(prop)} = ${value}`);
target[prop] = value;
return true; // required: signal success
},
});
proxiedUser.name; // logs: "Reading name"
proxiedUser.age = 30; // logs: "Writing age = 30"The original user object is untouched in structure — but every access now flows through your handler functions.
The 13 Trap Handlers
Proxy handlers define "traps" — methods that intercept specific operations. Here are the ones you'll actually use:
| Trap | Intercepts | Example Use Case |
|---|---|---|
get | obj.prop | Computed properties, access logging |
set | obj.prop = x | Validation, reactivity triggers |
has | 'prop' in obj | Hiding internal properties |
deleteProperty | delete obj.prop | Prevent deletion of keys |
ownKeys | Object.keys(obj) | Filtering enumerable keys |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor | Making props non-configurable |
defineProperty | Object.defineProperty | Controlling property definitions |
apply | function() call | Wrapping function behaviour |
construct | new Class() | Validating constructor args |
The less common ones (preventExtensions, isExtensible, getPrototypeOf, setPrototypeOf) deal with prototype manipulation and are rarely needed in day-to-day work.
The apply Trap — Function Middleware
The apply trap lets you wrap function calls. This is how you'd build a memoisation wrapper or a debounce utility:
function memoize(fn) {
const cache = new Map();
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
},
});
}
const slowFib = memoize(function fib(n) {
return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
});
slowFib(40); // instant — cached after first callThe construct Trap — Validating Instantiation
const SafeDate = new Proxy(Date, {
construct(target, args) {
if (args.length > 0 && typeof args[0] === 'string') {
const d = new target(...args);
if (isNaN(d.getTime())) {
throw new TypeError(`Invalid date string: ${args[0]}`);
}
}
return Reflect.construct(target, args);
},
});
new SafeDate('2026-08-10'); // works
new SafeDate('not-a-date'); // throws TypeErrorEnter Reflect
You'll notice I used Reflect.apply and Reflect.construct in the examples above. Reflect is Proxy's companion API — it provides the default behaviour for every trap.
Every Proxy trap has a matching Reflect method with the same arguments:
const handler = {
get(target, prop, receiver) {
console.log(`GET ${String(prop)}`);
return Reflect.get(target, prop, receiver); // forwards to default behaviour
},
set(target, prop, value, receiver) {
console.log(`SET ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
},
};Why Reflect Matters
Without Reflect, you'd write return target[prop] in your get trap. That works — until the target has a getter that uses this, or the prototype chain matters. Reflect ensures the operation behaves exactly as it would without the proxy.
Consider this subtle bug:
const obj = {};
Object.defineProperty(obj, 'secret', {
get() {
return this === globalProxy ? 'matched' : 'nope';
},
});
const globalProxy = new Proxy(obj, {
get(target, prop) {
return target[prop]; // ❌ `this` inside the getter is `target`, not `globalProxy`
},
});
const fixedProxy = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); // ✅ `this` is `globalProxy`
},
});The receiver parameter is the object the property was originally accessed on. Passing it through to Reflect.get preserves correct this binding in getters. This is the kind of detail that separates a toy proxy from a production one.
Real-World Patterns
Let's build the patterns you'll actually use in production.
1. Reactive State (Vue-Style)
This is the fundamental idea behind Vue 3's reactivity system:
function reactive(obj) {
const subscribers = new Set();
function notify() {
subscribers.forEach((fn) => fn());
}
return {
proxy: new Proxy(obj, {
get(target, prop) {
track(this, prop);
const value = Reflect.get(target, prop);
return typeof value === 'object' && value !== null
? reactive(value).proxy
: value;
},
set(target, prop, value) {
const oldValue = target[prop];
if (oldValue === value) return true;
target[prop] = value;
notify();
return true;
},
}),
subscribe(fn) {
subscribers.add(fn);
},
};
}
// Usage
const state = reactive({ count: 0, name: 'Clicks' });
state.subscribe(() => {
document.title = `${state.proxy.count} ${state.proxy.name}`;
});
state.proxy.count++; // document title updates automaticallyEvery framework's reactivity system — Vue 3, MobX, Solid.js — builds on this foundation. The Proxy intercepts the get (to register dependencies) and the set (to trigger updates).
2. Deep Property Validation
function validated(obj, schema) {
return new Proxy(obj, {
set(target, prop, value) {
if (prop in schema) {
const validator = schema[prop];
if (!validator(value)) {
throw new TypeError(`Invalid value for "${String(prop)}": ${value}`);
}
}
target[prop] = value;
return true;
},
});
}
const config = validated(
{ port: 3000, host: 'localhost' },
{
port: (v) => typeof v === 'number' && v > 0 && v < 65536,
host: (v) => typeof v === 'string' && v.length > 0,
}
);
config.port = 8080; // ✅
config.port = -1; // ❌ throws TypeError
config.host = ''; // ❌ throws TypeError3. Private Properties Without Closures
JavaScript class private fields (#private) are great, but if you need privacy on plain objects, Proxy has you covered:
function withObjectPrivacy(obj) {
const privatePrefix = '_';
return new Proxy(obj, {
get(target, prop) {
if (typeof prop === 'string' && prop.startsWith(privatePrefix)) {
return undefined; // hides private properties from reads
}
return Reflect.get(target, prop);
},
has(target, prop) {
if (typeof prop === 'string' && prop.startsWith(privatePrefix)) {
return false; // hides from `in` checks
}
return Reflect.has(target, prop);
},
ownKeys(target) {
return Reflect.ownKeys(target).filter(
(key) => !key.toString().startsWith(privatePrefix)
);
},
});
}
const api = withObjectPrivacy({
_apiKey: 'sk-secret-123',
endpoint: '/v1/data',
fetch() {
// internal code can still access this._apiKey
return fetch(this.endpoint, {
headers: { Authorization: `Bearer ${this._apiKey}` },
});
},
});
console.log(api._apiKey); // undefined
console.log('_apiKey' in api); // false
console.log(Object.keys(api)); // ['endpoint', 'fetch']4. Negative Array Indices
Python lets you do arr[-1] to get the last element. JavaScript doesn't — but with Proxies:
function negativeIndexArray(arr) {
return new Proxy(arr, {
get(target, prop) {
if (typeof prop === 'string') {
const index = Number(prop);
if (!isNaN(index) && index < 0) {
return target[target.length + index];
}
}
return Reflect.get(target, prop);
},
});
}
const arr = negativeIndexArray(['a', 'b', 'c', 'd']);
arr[-1]; // 'd'
arr[-2]; // 'c'5. Automatic API Client
Generate HTTP client methods from a config object — no manual fetch wrapping:
function createApiClient(baseUrl, endpoints) {
return new Proxy({}, {
get(_, namespace) {
return new Proxy({}, {
get(_, method) {
return (...args) => {
const path = endpoints[namespace]?.[method];
if (!path) throw new Error(`Unknown API: ${namespace}.${method}`);
const [params, options] = args;
const url = baseUrl + path(params);
return fetch(url, options).then((r) => r.json());
};
},
});
},
});
}
const api = createApiClient('https://api.example.com', {
users: {
getById: (p) => `/users/${p.id}`,
list: () => `/users`,
},
posts: {
getByUser: (p) => `/users/${p.id}/posts`,
},
});
// Clean, discoverable API
await api.users.getById({ id: 42 });
await api.users.list();
await api.posts.getByUser({ id: 42 });Proxy vs Object.defineProperty
Before Proxies (ES6), frameworks like Vue 2 used Object.defineProperty to achieve reactivity. Here's why Proxies won:
| Feature | Object.defineProperty | Proxy |
|---|---|---|
| Detect new properties | ❌ Need manual $set call | ✅ Automatic |
| Detect array index changes | ❌ | ✅ |
Detect delete operations | ❌ Need manual $delete | ✅ |
| Performance overhead | Per-property, applied once | Per-access, but engines optimise |
| Browser support | IE9+ | ES6+ (all modern browsers) |
Vue 3's move from Object.defineProperty to Proxy eliminated an entire category of reactivity edge cases. You no longer need Vue.set() or $delete() — Proxies catch every mutation.
Performance Considerations
Proxies add overhead per property access. In practice, this is negligible for most applications, but there are guidelines:
-
Don't proxy tight loops. If you're iterating 100,000 items, access the raw object directly.
-
Proxy once, not deeply. Instead of making every nested object a proxy upfront (expensive), use lazy proxying — proxy nested objects only when they're accessed (like Vue 3 does).
-
Avoid
hasandownKeystraps in hot paths. These fire on operations likefor...inloops andObject.keys(), which can be frequent. -
Revoke when done. If you create a proxy that should no longer be used, create it with
Proxy.revocable:
const { proxy, revoke } = Proxy.revocable(sensitiveData, {
get(target, prop) {
return Reflect.get(target, prop);
},
});
// Use it
console.log(proxy.secret);
// Later — permanently disable
revoke();
console.log(proxy.secret); // TypeError: illegal operation attempted on a revoked proxyCommon Pitfalls
Forgetting return true in set
The set trap must return true in strict mode to indicate success. Forgetting this throws a TypeError:
const p = new Proxy({}, {
set(target, prop, value) {
target[prop] = value;
// forgot return true!
},
});
p.x = 1; // TypeError: 'set' on proxy: trap returned falsish for property 'x'JSON.stringify and Proxies
JSON.stringify iterates using ownKeys and getOwnPropertyDescriptor. If your proxy doesn't implement these traps correctly, serialisation can produce unexpected results:
const p = new Proxy({ a: 1, b: 2 }, {
get(target, prop) {
return Reflect.get(target, prop);
},
});
JSON.stringify(p); // works — default traps are usedBut if you customise ownKeys to filter properties, JSON.stringify will respect that filtering — which may or may not be what you want.
Proxying Class Instances
Proxying a class instance can break instanceof checks unless you implement getPrototypeOf:
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
const dog = new Animal('Rex');
const proxiedDog = new Proxy(dog, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
});
proxiedDog instanceof Animal; // true (default getPrototypeOf works)
proxiedDog.speak(); // "Rex makes a sound"This works because the default proxy behaviour forwards prototype checks. But if you add a getPrototypeOf trap, be careful to return the right prototype.
Key Takeaways
- Proxy wraps an object and lets you intercept 13 fundamental operations —
get,set,has,apply,construct, and more. - Reflect provides the default implementation for each trap. Always use
Reflect.*methods instead of manually forwarding operations — it handles edge cases likethisbinding in getters. - Real-world use cases: reactivity systems (Vue 3, MobX), validation layers, private property patterns, API client generation, and memoisation.
- Performance is fine for normal application code. Avoid proxying in tight loops, use lazy deep proxying, and consider
Proxy.revocablefor temporary wrappers. - Always return
truefromsettraps in strict mode, or you'll get aTypeError.
The Proxy API is one of those features that seems niche until you build something with it — then you wonder how you ever lived without it. Start with the logging proxy (intercept every get and set), then work your way up to reactivity and validation patterns. The same primitives that power Vue 3 and MobX are available to you, right now, in every modern browser.