JavaScript Proxy & Reflect in 2026: The Complete Guide
Table of Contents
- What is a JavaScript Proxy?
- Creating Your First Proxy
- Essential Handler Traps
- The Reflect API Explained
- Data Validation with Proxy
- Building Reactive Systems
- Automatic Logging & Debugging
- Negative Array Indexing
- Revocable Proxies
- Proxy & Reflect Patterns Combined
- Performance Considerations
- Common Mistakes to Avoid
- Conclusion
JavaScript Proxy and Reflect are two of the most powerful yet underused features in the language. Together, they let you intercept and customize fundamental operations on objects — from property access to function calls. If you’ve ever wondered how frameworks like Vue 3 implement reactivity, or how libraries create “magic” objects that validate themselves, the answer is almost always Proxy and Reflect.
In this guide, you’ll learn everything about JavaScript Proxy and Reflect from the ground up, with practical examples you can use in production code today. Whether you’re building JavaScript objects that validate their own data or creating observable state systems, this tutorial has you covered.
What is a JavaScript Proxy?
A JavaScript Proxy wraps an object and lets you intercept operations performed on it. Think of it as a middleman that sits between your code and the target object. Every time someone reads a property, writes a value, deletes a key, or calls a function, the Proxy can intercept that operation and do something custom.
const target = { name: "Alice", age: 30 };
const handler = {
get(target, property, receiver) {
console.log(`Reading property: ${property}`);
return Reflect.get(target, property, receiver);
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.name); // Logs "Reading property: name" then "Alice"
The Proxy constructor takes two arguments: the target (the object being wrapped) and the handler (an object containing trap methods). Each trap corresponds to a fundamental operation — get for reading, set for writing, deleteProperty for deleting, and many more.
Creating Your First Proxy
Let’s build a practical Proxy that provides default values for missing properties. Normally, accessing a nonexistent property returns undefined. With a Proxy, you can change that behavior entirely.
const defaults = {
theme: "dark",
language: "en",
fontSize: 16,
notifications: true
};
const settings = {};
const handler = {
get(target, property) {
if (property in target) {
return target[property];
}
return defaults[property] ?? `Unknown setting: ${property}`;
}
};
const config = new Proxy(settings, handler);
console.log(config.theme); // "dark" (from defaults)
config.theme = "light";
console.log(config.theme); // "light" (user override)
console.log(config.fontSize); // 16 (from defaults)
console.log(config.unknownProp); // "Unknown setting: unknownProp"
This pattern is incredibly useful for configuration objects. Instead of littering your code with fallback checks, the Proxy handles defaults transparently. The consuming code doesn’t even know it’s working with a Proxy — it just accesses properties normally.
Essential Handler Traps
The Proxy handler supports 13 different traps. Here are the ones you’ll use most often in JavaScript Proxy and Reflect code.
The get Trap
Intercepts property reads, including bracket notation and dot notation access.
const handler = {
get(target, property, receiver) {
// property is always a string or Symbol
// receiver is the proxy itself (or object inheriting from it)
if (typeof target[property] === "function") {
return target[property].bind(target);
}
return target[property];
}
};
The set Trap
Intercepts property writes. Must return true if the assignment succeeded or false (which throws a TypeError in strict mode) if it didn’t.
const handler = {
set(target, property, value, receiver) {
if (property === "age" && (typeof value !== "number" || value < 0)) {
throw new TypeError("Age must be a positive number");
}
target[property] = value;
return true; // signal success
}
};
The has Trap
Intercepts the in operator. This lets you control which properties appear to exist.
const handler = {
has(target, property) {
// Hide private properties from 'in' checks
if (property.startsWith("_")) {
return false;
}
return property in target;
}
};
const obj = new Proxy({ _secret: 42, name: "public" }, handler);
console.log("name" in obj); // true
console.log("_secret" in obj); // false (hidden!)
The deleteProperty Trap
Intercepts the delete operator. You can prevent certain properties from being deleted.
const handler = {
deleteProperty(target, property) {
if (property === "id") {
throw new Error("Cannot delete the id property");
}
delete target[property];
return true;
}
};
The apply Trap
Intercepts function calls. Only works when the target is a function.
function sum(a, b) {
return a + b;
}
const handler = {
apply(target, thisArg, argumentsList) {
console.log(`Called with: ${argumentsList}`);
const result = Reflect.apply(target, thisArg, argumentsList);
console.log(`Result: ${result}`);
return result;
}
};
const trackedSum = new Proxy(sum, handler);
trackedSum(3, 4); // Logs "Called with: 3,4" then "Result: 7"
The Reflect API Explained
The Reflect API is a built-in object that provides methods matching every Proxy trap. It serves two critical purposes: performing default operations inside trap handlers, and providing a functional alternative to operator-based syntax.
// Instead of: target[property]
Reflect.get(target, "name");
// Instead of: target[property] = value
Reflect.set(target, "name", "Alice");
// Instead of: delete target[property]
Reflect.deleteProperty(target, "name");
// Instead of: property in target
Reflect.has(target, "name");
// Instead of: Object.keys(target)
Reflect.ownKeys(target);
// Instead of: new Target(...args)
Reflect.construct(Array, [1, 2, 3]);
Why use Reflect instead of direct operations? Reflect methods return booleans indicating success or failure instead of throwing errors. They also correctly forward the receiver parameter, which matters for proper this binding in class hierarchies.
// Direct assignment throws on frozen objects
const frozen = Object.freeze({ x: 1 });
try {
frozen.x = 2; // TypeError in strict mode, silent fail otherwise
} catch (e) {
console.log("Error:", e.message);
}
// Reflect.set returns false instead of throwing
const result = Reflect.set(frozen, "x", 2);
console.log(result); // false — no error, just a return value
Data Validation with Proxy
One of the most practical uses of JavaScript Proxy is automatic data validation. Instead of manually validating every property assignment, you define the rules once and the Proxy enforces them everywhere.
function createValidatedObject(schema) {
return new Proxy({}, {
set(target, property, value) {
if (!(property in schema)) {
throw new Error(`Unknown property: ${property}`);
}
const rule = schema[property];
if (rule.type && typeof value !== rule.type) {
throw new TypeError(
`${property} must be ${rule.type}, got ${typeof value}`
);
}
if (rule.min !== undefined && value < rule.min) {
throw new RangeError(
`${property} must be >= ${rule.min}, got ${value}`
);
}
if (rule.max !== undefined && value > rule.max) {
throw new RangeError(
`${property} must be <= ${rule.max}, got ${value}`
);
}
if (rule.pattern && !rule.pattern.test(value)) {
throw new Error(`${property} doesn't match required pattern`);
}
target[property] = value;
return true;
}
});
}
const userSchema = {
name: { type: "string" },
age: { type: "number", min: 0, max: 150 },
email: { type: "string", pattern: /^[^@]+@[^@]+\.[^@]+$/ }
};
const user = createValidatedObject(userSchema);
user.name = "Alice"; // OK
user.age = 30; // OK
user.email = "alice@test.com"; // OK
// user.age = -5; // RangeError: age must be >= 0
// user.email = "not-email"; // Error: email doesn't match pattern
// user.phone = "555-1234"; // Error: Unknown property: phone
This pattern is similar to how form validation works, but applied at the object level. Every property assignment is automatically checked against the schema — no manual validation calls needed.
Building Reactive Systems
Vue 3's entire reactivity system is built on Proxy. Here's a simplified version that shows how reactive state tracking works under the hood.
function reactive(obj) {
const subscribers = new Map();
function notify(property) {
const callbacks = subscribers.get(property) || [];
callbacks.forEach(cb => cb());
}
const proxy = new Proxy(obj, {
get(target, property) {
// Track which properties are accessed
if (activeEffect && !property.startsWith("_")) {
if (!subscribers.has(property)) {
subscribers.set(property, new Set());
}
subscribers.get(property).add(activeEffect);
}
return Reflect.get(target, property);
},
set(target, property, value) {
const oldValue = target[property];
const result = Reflect.set(target, property, value);
if (oldValue !== value) {
notify(property);
}
return result;
}
});
return proxy;
}
let activeEffect = null;
function watchEffect(fn) {
activeEffect = fn;
fn(); // Run once to collect dependencies
activeEffect = null;
}
// Usage
const state = reactive({ count: 0, message: "Hello" });
watchEffect(() => {
console.log(`Count is: ${state.count}`);
});
state.count = 1; // Logs "Count is: 1"
state.count = 2; // Logs "Count is: 2"
When watchEffect runs the callback, any property reads are tracked via the get trap. Later, when those properties change via the set trap, all registered callbacks are re-executed. This is the core idea behind modern frontend reactivity — and it's all powered by Proxy and Reflect.
Automatic Logging & Debugging
Proxies are perfect for adding transparent logging to any object. You can see exactly what operations are performed without modifying the original code.
function createLogger(target, label = "Object") {
return new Proxy(target, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === "function") {
return function (...args) {
console.log(`${label}.${property}(${args.join(", ")})`);
const result = value.apply(target, args);
console.log(` -> returned: ${JSON.stringify(result)}`);
return result;
};
}
console.log(`${label}.${property} -> ${JSON.stringify(value)}`);
return value;
},
set(target, property, value) {
console.log(`${label}.${property} = ${JSON.stringify(value)}`);
return Reflect.set(target, property, value);
}
});
}
const api = createLogger({
users: [],
addUser(name) {
this.users.push(name);
return this.users.length;
}
}, "API");
api.addUser("Alice");
// Logs: API.addUser(Alice)
// Logs: API.users -> [] (internal access by push)
// Logs: -> returned: 1
This debugging technique is invaluable during development. Wrap any suspicious object in a logger Proxy to trace exactly how it's being used. When debugging is done, simply remove the Proxy wrapper — the rest of your code stays unchanged. This is far cleaner than adding console.log statements throughout your functions.
Negative Array Indexing
Python developers love negative indexing (arr[-1] for the last element). JavaScript doesn't support it natively, but Proxy makes it trivial to add.
function createNegativeArray(arr) {
return new Proxy(arr, {
get(target, property, receiver) {
const index = Number(property);
if (!Number.isNaN(index) && index < 0) {
return target[target.length + index];
}
return Reflect.get(target, property, receiver);
},
set(target, property, value) {
const index = Number(property);
if (!Number.isNaN(index) && index < 0) {
target[target.length + index] = value;
return true;
}
return Reflect.set(target, property, value);
}
});
}
const arr = createNegativeArray([10, 20, 30, 40, 50]);
console.log(arr[-1]); // 50
console.log(arr[-2]); // 40
arr[-1] = 99;
console.log(arr); // [10, 20, 30, 40, 99]
Revocable Proxies
Sometimes you need to grant temporary access to an object and revoke it later. Proxy.revocable() creates a Proxy that can be permanently disabled.
function createTemporaryAccess(data, timeout) {
const { proxy, revoke } = Proxy.revocable(data, {
get(target, property) {
return Reflect.get(target, property);
}
});
// Auto-revoke after timeout
setTimeout(() => {
revoke();
console.log("Access revoked!");
}, timeout);
return proxy;
}
const sensitiveData = { apiKey: "sk-secret-123", dbPassword: "p@ss" };
const temp = createTemporaryAccess(sensitiveData, 5000);
console.log(temp.apiKey); // "sk-secret-123"
// After 5 seconds:
// temp.apiKey -> TypeError: Cannot perform 'get' on a proxy
// that has been revoked
This is useful for security-sensitive code where you want to ensure data access is time-limited. Once revoked, any operation on the Proxy throws a TypeError — there's no way to bypass it.
Proxy & Reflect Patterns Combined
Here's a powerful pattern that combines Proxy and Reflect to create an immutable view of an object. The original can still be modified, but the view always blocks mutations.
function readonlyView(target) {
return new Proxy(target, {
set() {
throw new Error("Cannot modify a readonly view");
},
deleteProperty() {
throw new Error("Cannot delete from a readonly view");
},
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
// Deep readonly — wrap nested objects too
if (typeof value === "object" && value !== null) {
return readonlyView(value);
}
return value;
}
});
}
const state = { user: { name: "Alice", scores: [90, 85, 92] } };
const view = readonlyView(state);
console.log(view.user.name); // "Alice"
// view.user.name = "Bob"; // Error: Cannot modify
// view.user.scores.push(100); // Error: Cannot modify
state.user.name = "Bob"; // Original still mutable
console.log(view.user.name); // "Bob" — view reflects changes
Performance Considerations
JavaScript Proxy and Reflect add overhead to every trapped operation. Here's what you need to know about performance.
Proxy operations are typically 2-5x slower than direct property access. For most applications, this is negligible — a Proxy get trap adds roughly 50-100 nanoseconds per access. However, in tight loops processing millions of iterations, the overhead can matter.
// Benchmark: Direct vs Proxy access
const direct = { x: 1 };
const proxied = new Proxy({ x: 1 }, {
get(t, p) { return Reflect.get(t, p); }
});
console.time("direct");
for (let i = 0; i < 10_000_000; i++) { direct.x; }
console.timeEnd("direct"); // ~15ms
console.time("proxy");
for (let i = 0; i < 10_000_000; i++) { proxied.x; }
console.timeEnd("proxy"); // ~60ms
Best practices for Proxy performance: use them for objects that aren't accessed in hot loops, keep trap logic minimal, and cache computed results when possible. For array operations on large datasets, consider whether the Proxy overhead is justified.
Common Mistakes to Avoid
The most common Proxy mistake is forgetting to return true from the set trap. In strict mode, returning undefined (the default) throws a TypeError. Always explicitly return true after a successful set.
Another frequent issue is Proxy invariant violations. The JavaScript spec enforces certain invariants — for example, if a target property is non-configurable and non-writable, the get trap must return the same value as the target. Violating these invariants throws a TypeError.
// This WILL throw
const target = {};
Object.defineProperty(target, "x", {
value: 42,
writable: false,
configurable: false
});
const proxy = new Proxy(target, {
get() { return 100; } // TypeError! Must return 42
});
Finally, be careful with this inside trapped methods. When a method is called through a Proxy, this refers to the Proxy, not the target. This can cause issues with private fields and internal slots. For methods that rely on internal state, bind them to the target in the get trap, as shown in the closures and this keyword guides.
Conclusion
JavaScript Proxy and Reflect unlock metaprogramming capabilities that were previously impossible. You've learned how to intercept property access, validate data automatically, build reactive systems, add transparent logging, implement negative indexing, and create revocable access controls.
The key takeaway: Proxy and Reflect are most powerful when used to add behavior transparently. The consuming code shouldn't need to know it's working with a Proxy. Use them for cross-cutting concerns like validation, logging, access control, and reactivity — patterns where you want to affect all operations on an object without modifying each call site.
In the next lesson, we'll explore localStorage and sessionStorage for persisting data in the browser.