JavaScript Proxies: The Most Underused Power Tool in Your Arsenal
JavaScript Proxies: The Most Underused Power Tool in Your Arsenal
Every JavaScript developer knows about Proxy. Almost nobody uses it.
The tutorials show toy examples—logging property access, making properties read-only. Cute. Useless. They don't show you that Vue's reactivity system, MobX's observables, and Immer's immutable updates are all built on Proxy.
This is the practical guide to using Proxy for real architectural problems.
What Proxy Actually Is
Proxy lets you intercept and redefine fundamental operations on objects: property access, assignment, function calls, construction, and more.
┌─────────────────────────────────────────────────────────────────────┐
│ PROXY ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Consumer Proxy Target │
│ ──────── ───── ────── │
│ │
│ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ Handler │ │ Actual │ │
│ │ Your │ ──────▶ │ (Traps) │ ───────▶ │ Object │ │
│ │ Code │ │ │ │ │ │
│ │ │ ◀────── │ get, set, │ ◀─────── │ │ │
│ └─────────┘ │ apply, etc │ └─────────────┘ │
│ └─────────────┘ │
│ │
│ const proxy = new Proxy(target, handler); │
│ │
│ // When you do this: // This runs: │
│ proxy.name handler.get(target, 'name', proxy) │
│ proxy.name = 'Alice' handler.set(target, 'name', 'Alice') │
│ delete proxy.name handler.deleteProperty(target,'name')│
│ 'name' in proxy handler.has(target, 'name') │
│ proxy() handler.apply(target, this, args) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Available Traps
const handler: ProxyHandler<any> = {
// Property access
get(target, prop, receiver) {},
set(target, prop, value, receiver) {},
has(target, prop) {}, // 'prop' in proxy
deleteProperty(target, prop) {},
// Object operations
ownKeys(target) {}, // Object.keys(), for...in
getOwnPropertyDescriptor(target, prop) {},
defineProperty(target, prop, descriptor) {},
// Prototype
getPrototypeOf(target) {},
setPrototypeOf(target, proto) {},
// Extensibility
isExtensible(target) {},
preventExtensions(target) {},
// Function calls (when target is a function)
apply(target, thisArg, args) {},
construct(target, args, newTarget) {}, // new proxy()
};
Reflect: The Missing Piece
Reflect provides default implementations for all trap operations. Always use it in handlers:
const handler = {
get(target, prop, receiver) {
console.log(`Accessing ${String(prop)}`);
// ✅ Use Reflect, not target[prop]
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`Setting ${String(prop)} to ${value}`);
// ✅ Returns boolean indicating success
return Reflect.set(target, prop, value, receiver);
}
};
Why Reflect over direct access?
- Correct handling of getters/setters with proper
this - Returns success/failure booleans
- Works with all proxy traps consistently
- Handles inherited properties correctly
Use Case 1: Reactive State
This is how Vue 3's reactivity works. When state changes, effects re-run automatically.
The Problem
// Manual subscription management is painful
const state = { count: 0 };
const listeners: (() => void)[] = [];
function subscribe(fn: () => void) {
listeners.push(fn);
}
function setState(updates: Partial<typeof state>) {
Object.assign(state, updates);
listeners.forEach(fn => fn()); // Notify all, even if irrelevant
}
// Have to remember to call setState, can't just assign
setState({ count: 1 }); // Works
state.count = 2; // Doesn't trigger listeners!
The Proxy Solution
type Effect = () => void;
// Track which effect is currently running
let activeEffect: Effect | null = null;
// Map of property -> effects that depend on it
const targetMap = new WeakMap<object, Map<string | symbol, Set<Effect>>>();
function track(target: object, prop: string | symbol) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(prop);
if (!deps) {
deps = new Set();
depsMap.set(prop, deps);
}
deps.add(activeEffect);
}
function trigger(target: object, prop: string | symbol) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(prop);
if (effects) {
effects.forEach(effect => effect());
}
}
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, prop, receiver) {
track(target, prop);
const value = Reflect.get(target, prop, receiver);
// Deep reactivity - wrap nested objects
if (value && typeof value === 'object') {
return reactive(value);
}
return value;
},
set(target, prop, value, receiver) {
const oldValue = Reflect.get(target, prop, receiver);
const result = Reflect.set(target, prop, value, receiver);
if (oldValue !== value) {
trigger(target, prop);
}
return result;
}
});
}
function effect(fn: Effect) {
activeEffect = fn;
fn(); // Run immediately to collect dependencies
activeEffect = null;
}
Using It
const state = reactive({
user: { name: 'Alice', age: 30 },
items: ['a', 'b', 'c']
});
// This effect automatically tracks its dependencies
effect(() => {
console.log(`User: ${state.user.name}, Age: ${state.user.age}`);
});
// Output: User: Alice, Age: 30
// Only re-runs effects that depend on changed properties
state.user.name = 'Bob';
// Output: User: Bob, Age: 30
state.user.age = 31;
// Output: User: Bob, Age: 31
// This doesn't trigger the above effect (no dependency)
state.items.push('d');
Production-Grade Reactive System
// Full implementation with batching and cleanup
class ReactiveSystem {
private activeEffect: Effect | null = null;
private effectStack: Effect[] = [];
private targetMap = new WeakMap<object, Map<string | symbol, Set<Effect>>>();
private pendingEffects = new Set<Effect>();
private isFlushing = false;
reactive<T extends object>(target: T): T {
// Prevent double-wrapping
if ((target as any).__isReactive) {
return target;
}
const proxy = new Proxy(target, {
get: (target, prop, receiver) => {
if (prop === '__isReactive') return true;
if (prop === '__raw') return target;
this.track(target, prop);
const value = Reflect.get(target, prop, receiver);
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
return this.reactive(value);
}
return value;
},
set: (target, prop, value, receiver) => {
const oldValue = Reflect.get(target, prop, receiver);
// Unwrap reactive values before setting
const rawValue = value?.__raw ?? value;
const result = Reflect.set(target, prop, rawValue, receiver);
if (!Object.is(oldValue, rawValue)) {
this.trigger(target, prop);
}
return result;
},
deleteProperty: (target, prop) => {
const hadKey = Reflect.has(target, prop);
const result = Reflect.deleteProperty(target, prop);
if (hadKey && result) {
this.trigger(target, prop);
}
return result;
}
});
return proxy;
}
private track(target: object, prop: string | symbol) {
if (!this.activeEffect) return;
let depsMap = this.targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
this.targetMap.set(target, depsMap);
}
let deps = depsMap.get(prop);
if (!deps) {
deps = new Set();
depsMap.set(prop, deps);
}
deps.add(this.activeEffect);
}
private trigger(target: object, prop: string | symbol) {
const depsMap = this.targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(prop);
if (!effects) return;
// Batch effects
effects.forEach(effect => this.pendingEffects.add(effect));
this.flush();
}
private flush() {
if (this.isFlushing) return;
this.isFlushing = true;
// Use microtask for batching
queueMicrotask(() => {
this.pendingEffects.forEach(effect => {
// Prevent infinite loops
if (effect !== this.activeEffect) {
this.runEffect(effect);
}
});
this.pendingEffects.clear();
this.isFlushing = false;
});
}
effect(fn: Effect): () => void {
const cleanup = this.runEffect(fn);
return cleanup;
}
private runEffect(fn: Effect): () => void {
this.effectStack.push(fn);
this.activeEffect = fn;
try {
fn();
} finally {
this.effectStack.pop();
this.activeEffect = this.effectStack[this.effectStack.length - 1] ?? null;
}
// Return cleanup function
return () => {
// Remove this effect from all dependency sets
this.targetMap.forEach(depsMap => {
depsMap.forEach(deps => {
deps.delete(fn);
});
});
};
}
computed<T>(getter: () => T): { readonly value: T } {
let cachedValue: T;
let dirty = true;
const runner = () => {
if (dirty) {
cachedValue = getter();
dirty = false;
}
};
// Track getter dependencies
this.effect(() => {
getter();
dirty = true;
});
return {
get value() {
runner();
return cachedValue;
}
};
}
}
// Usage
const system = new ReactiveSystem();
const state = system.reactive({
firstName: 'John',
lastName: 'Doe'
});
const fullName = system.computed(() => `${state.firstName} ${state.lastName}`);
system.effect(() => {
console.log('Full name:', fullName.value);
});
state.firstName = 'Jane'; // Logs: Full name: Jane Doe
Use Case 2: Validation Layer
Validate data at the boundary without polluting business logic.
Schema-Based Validation
type Validator<T> = (value: unknown, path: string) => T;
interface SchemaDefinition {
[key: string]: Validator<any> | SchemaDefinition;
}
// Validator factories
const validators = {
string: (minLength = 0, maxLength = Infinity): Validator<string> =>
(value, path) => {
if (typeof value !== 'string') {
throw new TypeError(`${path} must be a string, got ${typeof value}`);
}
if (value.length < minLength) {
throw new RangeError(`${path} must be at least ${minLength} characters`);
}
if (value.length > maxLength) {
throw new RangeError(`${path} must be at most ${maxLength} characters`);
}
return value;
},
number: (min = -Infinity, max = Infinity): Validator<number> =>
(value, path) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeError(`${path} must be a number`);
}
if (value < min || value > max) {
throw new RangeError(`${path} must be between ${min} and ${max}`);
}
return value;
},
email: (): Validator<string> =>
(value, path) => {
const str = validators.string(1, 255)(value, path);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str)) {
throw new Error(`${path} must be a valid email address`);
}
return str;
},
array: <T>(itemValidator: Validator<T>): Validator<T[]> =>
(value, path) => {
if (!Array.isArray(value)) {
throw new TypeError(`${path} must be an array`);
}
return value.map((item, i) => itemValidator(item, `${path}[${i}]`));
},
optional: <T>(validator: Validator<T>): Validator<T | undefined> =>
(value, path) => {
if (value === undefined) return undefined;
return validator(value, path);
}
};
function createValidatedProxy<T extends object>(
schema: SchemaDefinition,
initialData: Partial<T> = {}
): T {
const data: Record<string, any> = { ...initialData };
const handler: ProxyHandler<Record<string, any>> = {
get(target, prop, receiver) {
if (typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver);
}
return data[prop];
},
set(target, prop, value, receiver) {
if (typeof prop === 'symbol') {
return Reflect.set(target, prop, value, receiver);
}
const validator = schema[prop];
if (!validator) {
throw new Error(`Unknown property: ${prop}`);
}
if (typeof validator === 'function') {
// It's a validator function
const validated = validator(value, prop);
data[prop] = validated;
} else {
// It's a nested schema - create nested proxy
data[prop] = createValidatedProxy(validator, value);
}
return true;
},
deleteProperty(target, prop) {
if (typeof prop === 'string' && prop in schema) {
throw new Error(`Cannot delete required property: ${prop}`);
}
delete data[prop as string];
return true;
}
};
return new Proxy(data, handler) as T;
}
Using It
interface User {
name: string;
email: string;
age: number;
address: {
street: string;
city: string;
zip: string;
};
}
const userSchema: SchemaDefinition = {
name: validators.string(1, 100),
email: validators.email(),
age: validators.number(0, 150),
address: {
street: validators.string(1, 200),
city: validators.string(1, 100),
zip: validators.string(5, 10)
}
};
const user = createValidatedProxy<User>(userSchema);
user.name = 'Alice'; // ✅ Works
user.email = 'alice@test.com'; // ✅ Works
user.age = 30; // ✅ Works
user.name = ''; // ❌ Throws: name must be at least 1 characters
user.email = 'not-an-email'; // ❌ Throws: email must be a valid email address
user.age = -5; // ❌ Throws: age must be between 0 and 150
user.address = {
street: '123 Main St',
city: 'Springfield',
zip: '12345'
}; // ✅ Works, creates nested proxy
user.address.zip = '1'; // ❌ Throws: address.zip must be at least 5 characters
API Request/Response Validation
function createAPIClient<TEndpoints extends Record<string, EndpointDef>>(
baseURL: string,
endpoints: TEndpoints
) {
type EndpointDef = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
requestSchema?: SchemaDefinition;
responseSchema?: SchemaDefinition;
};
return new Proxy({} as any, {
get(target, endpoint: string) {
const def = endpoints[endpoint];
if (!def) {
throw new Error(`Unknown endpoint: ${endpoint}`);
}
return async (data?: any) => {
// Validate request
if (def.requestSchema && data) {
const validated = createValidatedProxy(def.requestSchema);
Object.assign(validated, data); // This validates each field
}
// Make request
const response = await fetch(`${baseURL}${def.path}`, {
method: def.method,
headers: { 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : undefined
});
const result = await response.json();
// Validate response
if (def.responseSchema) {
const validated = createValidatedProxy(def.responseSchema);
Object.assign(validated, result);
return validated;
}
return result;
};
}
});
}
// Usage
const api = createAPIClient('https://api.example.com', {
createUser: {
method: 'POST',
path: '/users',
requestSchema: userSchema,
responseSchema: {
id: validators.string(),
...userSchema
}
},
getUser: {
method: 'GET',
path: '/users/:id',
responseSchema: userSchema
}
});
// Request data is validated before sending
// Response data is validated before returning
const newUser = await api.createUser({
name: 'Bob',
email: 'bob@test.com',
age: 25
});
Use Case 3: Auto-Memoization
Automatically cache expensive computations without manual memoization code.
Function Memoization
function memoize<T extends (...args: any[]) => any>(
fn: T,
options: {
maxSize?: number;
ttl?: number;
keySerializer?: (...args: Parameters<T>) => string;
} = {}
): T {
const {
maxSize = 100,
ttl,
keySerializer = (...args) => JSON.stringify(args)
} = options;
const cache = new Map<string, { value: ReturnType<T>; timestamp: number }>();
const keyOrder: string[] = [];
return new Proxy(fn, {
apply(target, thisArg, args: Parameters<T>) {
const key = keySerializer(...args);
const now = Date.now();
// Check cache
const cached = cache.get(key);
if (cached) {
// Check TTL
if (!ttl || now - cached.timestamp < ttl) {
// Move to end (LRU)
const idx = keyOrder.indexOf(key);
if (idx > -1) {
keyOrder.splice(idx, 1);
keyOrder.push(key);
}
return cached.value;
}
// Expired
cache.delete(key);
keyOrder.splice(keyOrder.indexOf(key), 1);
}
// Compute
const result = Reflect.apply(target, thisArg, args);
// Handle promises
if (result instanceof Promise) {
return result.then(value => {
storeResult(key, value, now);
return value;
}).catch(error => {
// Don't cache errors
throw error;
});
}
storeResult(key, result, now);
return result;
function storeResult(key: string, value: any, timestamp: number) {
// Evict if at capacity
while (keyOrder.length >= maxSize) {
const oldestKey = keyOrder.shift()!;
cache.delete(oldestKey);
}
cache.set(key, { value, timestamp });
keyOrder.push(key);
}
}
}) as T;
}
Using It
const expensiveCalculation = memoize(
(x: number, y: number) => {
console.log('Computing...');
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(x * y + i);
}
return result;
},
{ maxSize: 50, ttl: 60000 } // 1 minute TTL
);
expensiveCalculation(10, 20); // Logs: Computing... (slow)
expensiveCalculation(10, 20); // Instant (cached)
expensiveCalculation(10, 20); // Instant (cached)
expensiveCalculation(30, 40); // Logs: Computing... (different args)
// Async support
const fetchUserMemoized = memoize(
async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
{ ttl: 30000 } // Cache for 30 seconds
);
await fetchUserMemoized('123'); // Fetches
await fetchUserMemoized('123'); // Cached
await fetchUserMemoized('456'); // Fetches
Object Property Memoization
Lazily compute and cache object properties:
function lazyObject<T extends object>(
factories: { [K in keyof T]: () => T[K] }
): T {
const cache: Partial<T> = {};
const computed = new Set<keyof T>();
return new Proxy(cache as T, {
get(target, prop: keyof T, receiver) {
if (computed.has(prop)) {
return cache[prop];
}
const factory = factories[prop];
if (!factory) {
return undefined;
}
console.log(`Computing ${String(prop)}...`);
const value = factory();
cache[prop] = value;
computed.add(prop);
return value;
},
has(target, prop: keyof T) {
return prop in factories;
},
ownKeys() {
return Reflect.ownKeys(factories);
},
getOwnPropertyDescriptor(target, prop) {
if (prop in factories) {
return {
configurable: true,
enumerable: true,
value: this.get!(target, prop as keyof T, target)
};
}
return undefined;
}
});
}
// Usage
const stats = lazyObject({
mean: () => calculateMean(hugeDataset), // Expensive
median: () => calculateMedian(hugeDataset), // Expensive
stdDev: () => calculateStdDev(hugeDataset), // Expensive
histogram: () => generateHistogram(hugeDataset) // Very expensive
});
// Only computed when accessed
console.log(stats.mean); // Logs: Computing mean... (computes)
console.log(stats.mean); // Instant (cached)
// histogram never computed if never accessed
Use Case 4: API Mocking and Testing
Create flexible mocks that record calls and return programmable responses.
The Mock Factory
interface MockCall {
method: string;
args: any[];
timestamp: number;
}
interface MockConfig {
returnValue?: any;
implementation?: (...args: any[]) => any;
throws?: Error;
sequence?: any[]; // Return values in sequence
}
function createMock<T extends object>(
defaultImplementation?: Partial<T>
): T & {
__calls: MockCall[];
__configure: (method: string, config: MockConfig) => void;
__reset: () => void;
__getCalls: (method?: string) => MockCall[];
} {
const calls: MockCall[] = [];
const configs: Map<string, MockConfig> = new Map();
const sequenceIndices: Map<string, number> = new Map();
const mock = new Proxy({} as any, {
get(target, prop: string) {
// Internal methods
if (prop === '__calls') return calls;
if (prop === '__configure') {
return (method: string, config: MockConfig) => {
configs.set(method, config);
sequenceIndices.set(method, 0);
};
}
if (prop === '__reset') {
return () => {
calls.length = 0;
configs.clear();
sequenceIndices.clear();
};
}
if (prop === '__getCalls') {
return (method?: string) => {
if (method) {
return calls.filter(c => c.method === method);
}
return calls;
};
}
// Return a function that records calls
return (...args: any[]) => {
calls.push({
method: prop,
args,
timestamp: Date.now()
});
const config = configs.get(prop);
if (config) {
if (config.throws) {
throw config.throws;
}
if (config.sequence) {
const idx = sequenceIndices.get(prop) || 0;
const value = config.sequence[idx % config.sequence.length];
sequenceIndices.set(prop, idx + 1);
return value;
}
if (config.implementation) {
return config.implementation(...args);
}
return config.returnValue;
}
// Check default implementation
const defaultMethod = defaultImplementation?.[prop as keyof T];
if (typeof defaultMethod === 'function') {
return (defaultMethod as Function)(...args);
}
return undefined;
};
}
});
return mock;
}
Using It
// Create a mock API client
interface UserAPI {
getUser(id: string): Promise<User>;
createUser(data: CreateUserDTO): Promise<User>;
deleteUser(id: string): Promise<void>;
}
const mockAPI = createMock<UserAPI>();
// Configure responses
mockAPI.__configure('getUser', {
implementation: async (id: string) => ({
id,
name: 'Mock User',
email: 'mock@test.com'
})
});
mockAPI.__configure('createUser', {
sequence: [
{ id: '1', name: 'First' },
{ id: '2', name: 'Second' },
{ id: '3', name: 'Third' }
]
});
mockAPI.__configure('deleteUser', {
throws: new Error('Deletion failed')
});
// Use in tests
const user = await mockAPI.getUser('123');
console.log(user); // { id: '123', name: 'Mock User', ... }
const user1 = await mockAPI.createUser({ name: 'Test' }); // { id: '1', name: 'First' }
const user2 = await mockAPI.createUser({ name: 'Test' }); // { id: '2', name: 'Second' }
// Verify calls
console.log(mockAPI.__getCalls('getUser'));
// [{ method: 'getUser', args: ['123'], timestamp: ... }]
console.log(mockAPI.__getCalls().length); // 3
// Reset between tests
mockAPI.__reset();
Spy Wrapper
Wrap a real object to spy on calls while maintaining real behavior:
function createSpy<T extends object>(target: T): T & {
__calls: MockCall[];
__getCalls: (method?: string) => MockCall[];
__reset: () => void;
} {
const calls: MockCall[] = [];
return new Proxy(target, {
get(target, prop: string, receiver) {
if (prop === '__calls') return calls;
if (prop === '__getCalls') {
return (method?: string) =>
method ? calls.filter(c => c.method === method) : calls;
}
if (prop === '__reset') {
return () => { calls.length = 0; };
}
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function') {
return (...args: any[]) => {
calls.push({ method: prop, args, timestamp: Date.now() });
return value.apply(target, args);
};
}
return value;
}
}) as any;
}
// Usage
const realAPI = new RealAPIClient();
const spiedAPI = createSpy(realAPI);
// Real methods are called, but calls are recorded
const user = await spiedAPI.getUser('123'); // Real API call
console.log(spiedAPI.__getCalls('getUser'));
// [{ method: 'getUser', args: ['123'], ... }]
Use Case 5: Immutable Data with Change Tracking
Like Immer, but you understand how it works.
type Patch = {
op: 'add' | 'replace' | 'remove';
path: (string | number)[];
value?: any;
oldValue?: any;
};
function createDraft<T extends object>(
base: T
): [T, () => { result: T; patches: Patch[] }] {
const patches: Patch[] = [];
const copies = new WeakMap<object, object>();
const modified = new WeakSet<object>();
function getOrCreateCopy<O extends object>(obj: O, path: (string | number)[]): O {
if (copies.has(obj)) {
return copies.get(obj) as O;
}
const copy = Array.isArray(obj) ? [...obj] : { ...obj };
copies.set(obj, copy);
modified.add(copy);
return copy as O;
}
function createProxy<O extends object>(obj: O, path: (string | number)[]): O {
return new Proxy(obj, {
get(target, prop, receiver) {
if (prop === '__isDraft') return true;
if (prop === '__path') return path;
const copy = copies.get(target) as O | undefined;
const source = copy ?? target;
const value = Reflect.get(source, prop, receiver);
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
const newPath = [...path, prop as string | number];
return createProxy(value, newPath);
}
return value;
},
set(target, prop, value, receiver) {
const copy = getOrCreateCopy(target, path);
const oldValue = Reflect.get(copy, prop, receiver);
const rawValue = value?.__isDraft
? copies.get(value) ?? value
: value;
Reflect.set(copy, prop, rawValue, receiver);
patches.push({
op: prop in target ? 'replace' : 'add',
path: [...path, prop as string],
value: rawValue,
oldValue
});
return true;
},
deleteProperty(target, prop) {
const copy = getOrCreateCopy(target, path);
const oldValue = Reflect.get(copy, prop);
Reflect.deleteProperty(copy, prop);
patches.push({
op: 'remove',
path: [...path, prop as string],
oldValue
});
return true;
}
});
}
const draft = createProxy(base, []);
const finalize = (): { result: T; patches: Patch[] } => {
// Return modified copy or original if unmodified
const result = (copies.get(base) ?? base) as T;
return { result, patches };
};
return [draft, finalize];
}
// Helper for Immer-like API
function produce<T extends object>(
base: T,
recipe: (draft: T) => void
): { result: T; patches: Patch[] } {
const [draft, finalize] = createDraft(base);
recipe(draft);
return finalize();
}
Using It
const state = {
users: [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
],
settings: {
theme: 'dark',
notifications: true
}
};
const { result, patches } = produce(state, draft => {
draft.users[0].name = 'Alicia';
draft.users.push({ id: 3, name: 'Charlie', role: 'user' });
draft.settings.theme = 'light';
});
console.log(state.users[0].name); // 'Alice' (unchanged)
console.log(result.users[0].name); // 'Alicia'
console.log(state === result); // false
console.log(state.settings === result.settings); // false (modified)
console.log(patches);
// [
// { op: 'replace', path: ['users', 0, 'name'], value: 'Alicia', oldValue: 'Alice' },
// { op: 'add', path: ['users', 2], value: { id: 3, name: 'Charlie', ... } },
// { op: 'replace', path: ['settings', 'theme'], value: 'light', oldValue: 'dark' }
// ]
// Patches can be sent to server, used for undo, etc.
Use Case 6: Virtual Objects and Lazy Loading
Create objects that load data on demand.
function createVirtualCollection<T>(
loader: (id: string) => Promise<T>,
options: { preload?: string[]; cacheSize?: number } = {}
): Record<string, T> {
const cache = new Map<string, T>();
const loading = new Map<string, Promise<T>>();
// Preload specified items
options.preload?.forEach(id => {
const promise = loader(id).then(item => {
cache.set(id, item);
loading.delete(id);
return item;
});
loading.set(id, promise);
});
return new Proxy({} as Record<string, T>, {
get(target, prop: string) {
// Return cached
if (cache.has(prop)) {
return cache.get(prop);
}
// Return loading promise if already loading
if (loading.has(prop)) {
throw loading.get(prop); // For Suspense
}
// Start loading
const promise = loader(prop).then(item => {
// Evict oldest if at capacity
if (options.cacheSize && cache.size >= options.cacheSize) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
cache.set(prop, item);
loading.delete(prop);
return item;
});
loading.set(prop, promise);
throw promise; // Suspend
},
has(target, prop: string) {
return true; // All keys potentially exist
},
ownKeys() {
return [...cache.keys()];
}
});
}
// Usage with React Suspense
const users = createVirtualCollection<User>(
async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
{ preload: ['currentUser'], cacheSize: 100 }
);
function UserProfile({ userId }: { userId: string }) {
const user = users[userId]; // Suspends until loaded
return <div>{user.name}</div>;
}
// Wrap with Suspense
<Suspense fallback={<Loading />}>
<UserProfile userId="123" />
</Suspense>
Performance Considerations
┌─────────────────────────────────────────────────────────────────────┐
│ PROXY PERFORMANCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ OVERHEAD │
│ ──────── │
│ • Proxy access is ~2-5x slower than direct property access │
│ • Still nanoseconds - irrelevant for most use cases │
│ • Becomes noticeable only in hot loops (millions of iterations) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Benchmark (1 million iterations): │ │
│ │ │ │
│ │ Direct access: ~10ms │ │
│ │ Proxy access: ~25ms │ │
│ │ Proxy with logic: ~40ms │ │
│ │ │ │
│ │ For normal app code: unmeasurable difference │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ WHEN TO WORRY │
│ ───────────── │
│ • Rendering thousands of items in a loop │
│ • Game loops or animation frames │
│ • High-frequency data processing │
│ │
│ WHEN NOT TO WORRY │
│ ──────────────── │
│ • Form state │
│ • API responses │
│ • Business logic │
│ • UI state management │
│ • 99% of application code │
│ │
│ OPTIMIZATION STRATEGIES │
│ ─────────────────────── │
│ • Proxy at the right level (not every nested object) │
│ • Cache derived values │
│ • Batch operations before proxying │
│ • Use Proxy for boundaries, plain objects for hot paths │
│ │
└─────────────────────────────────────────────────────────────────────┘
Anti-Patterns
// ❌ Proxying primitives (impossible)
// new Proxy(5, handler); // TypeError
// ❌ Not using Reflect (breaks getters/setters)
const bad = new Proxy(obj, {
get(target, prop) {
return target[prop]; // Wrong - doesn't handle getters correctly
}
});
// ✅ Use Reflect
const good = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});
// ❌ Forgetting that Proxy is transparent
const proxy = new Proxy(target, handler);
proxy === target; // false!
Object.keys(proxy); // Works (if ownKeys trap allows)
// ❌ Not handling symbols
const bad = new Proxy(obj, {
get(target, prop) {
console.log(prop); // Could be Symbol.iterator, etc.
// Handle symbols appropriately
}
});
// ❌ Creating new proxy on every access (memory leak)
const bad = new Proxy(obj, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'object') {
return new Proxy(value, handler); // New proxy every time!
}
return value;
}
});
// ✅ Cache nested proxies
const proxyCache = new WeakMap();
const good = new Proxy(obj, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'object') {
if (!proxyCache.has(value)) {
proxyCache.set(value, new Proxy(value, handler));
}
return proxyCache.get(value);
}
return value;
}
});
Decision Matrix
| Use Case | Proxy Appropriate? | Alternative |
|---|---|---|
| Reactive state | ✅ Yes | MobX, Vue reactivity |
| Validation | ✅ Yes | Zod (different approach) |
| Memoization | ✅ Yes | Manual cache, lodash.memoize |
| API mocking | ✅ Yes | Jest mocks, MSW |
| Immutable updates | ✅ Yes | Immer (uses Proxy internally) |
| Lazy loading | ✅ Yes | Explicit async functions |
| Simple property access | ❌ Overkill | Direct access |
| Hot loops | ❌ Too slow | Plain objects |
| Primitive values | ❌ Impossible | Wrapper objects |
Quick Reference
// Basic proxy
const proxy = new Proxy(target, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
return Reflect.set(target, prop, value, receiver);
}
});
// Revocable proxy (can be disabled)
const { proxy, revoke } = Proxy.revocable(target, handler);
revoke(); // proxy becomes unusable
// Check if object is a proxy (sort of)
const isProxy = (obj) => {
try {
// Proxies can't be used as WeakMap keys if revoked
return obj.__isProxy === true; // Add marker in handler
} catch {
return true; // Revoked proxy
}
};
// Common trap patterns
const handler = {
// Read-only
set() { throw new Error('Read-only'); },
deleteProperty() { throw new Error('Read-only'); },
// Hidden properties
get(t, p, r) {
if (p.startsWith('_')) return undefined;
return Reflect.get(t, p, r);
},
has(t, p) {
if (p.startsWith('_')) return false;
return Reflect.has(t, p);
},
ownKeys(t) {
return Reflect.ownKeys(t).filter(k =>
typeof k !== 'string' || !k.startsWith('_')
);
},
// Default values
get(t, p, r) {
const value = Reflect.get(t, p, r);
return value ?? defaults[p];
}
};
Closing Thoughts
Proxy is not a toy for logging property access. It's the foundation of modern JavaScript reactivity systems, validation layers, and testing infrastructure.
The key insight is that Proxy lets you intercept the language itself. Property access, assignment, deletion, enumeration—these aren't just operations, they're extension points.
Use Proxy when you need to:
- React to changes without manual subscription management
- Validate data at boundaries without polluting business logic
- Create mock objects that record behavior
- Build lazy-loading systems
- Implement copy-on-write immutability
Don't use Proxy when:
- Direct property access is sufficient
- You're in a hot loop
- You're trying to proxy primitives (use wrapper objects)
Master Proxy and you'll see opportunities everywhere—cleaner APIs, automatic behaviors, invisible infrastructure. That's the power tool you've been ignoring.
What did you think?