Proxy Pattern and Lazy Loading
Invisible Interception
What if you could make an object that looks and behaves exactly like a normal object — same properties, same methods — but secretly runs your code every time someone reads a property, writes a value, or even checks if a key exists? No special API. No method calls. Just obj.name triggers your interceptor.
const user = createReactiveObject({ name: "Alice", age: 30 });
user.name; // Reads "Alice" — AND tracks that you read "name"
user.age = 31; // Writes 31 — AND notifies all watchers of "age"
This is exactly how Vue 3's reactivity works. And it's powered by a single ES6 feature: Proxy.
Think of a Proxy like a transparent bodyguard standing in front of a celebrity (the target object). Fans think they're interacting with the celebrity directly — they can ask questions, hand over gifts, take photos. But the bodyguard intercepts every interaction first. They might allow it, modify it, block it, or log it. The fan never knows the bodyguard exists. The celebrity never sees interactions the bodyguard blocks. Total transparent control.
Proxy Fundamentals
A Proxy wraps a target object and intercepts operations via handler traps:
const target = { name: "Alice", age: 30 };
const handler: ProxyHandler<typeof target> = {
get(target, property, receiver) {
console.log(`Reading: ${String(property)}`);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`Writing: ${String(property)} = ${value}`);
return Reflect.set(target, property, value, receiver);
},
};
const proxy = new Proxy(target, handler);
proxy.name; // "Reading: name" → "Alice"
proxy.age = 31; // "Writing: age = 31"
Always use Reflect methods inside traps. Using target[property] directly bypasses the proxy for nested access and breaks getter/setter behavior.
Building a Validation Proxy
One of the most practical uses of Proxy is automatic validation on property assignment:
interface UserSchema {
name: string;
email: string;
age: number;
}
type Validator<T> = {
[K in keyof T]: (value: unknown) => value is T[K];
};
function createValidated<T extends object>(
initial: T,
validators: Validator<T>
): T {
return new Proxy(initial, {
set(target, property, value, receiver) {
const key = property as keyof T;
const validator = validators[key];
if (validator && !validator(value)) {
throw new TypeError(
`Invalid value for "${String(property)}": ${JSON.stringify(value)}`
);
}
return Reflect.set(target, property, value, receiver);
},
});
}
const user = createValidated(
{ name: "Alice", email: "alice@example.com", age: 30 },
{
name: (v): v is string => typeof v === "string" && v.length > 0,
email: (v): v is string => typeof v === "string" && v.includes("@"),
age: (v): v is number => typeof v === "number" && v >= 0 && v <= 150,
}
);
user.name = "Bob"; // Works
user.age = 25; // Works
// user.age = -5; // TypeError: Invalid value for "age": -5
// user.email = "invalid" // TypeError: Invalid value for "email": "invalid"
The consumer uses user like a plain object. They never know validation exists. This is the key advantage of Proxy over explicit setter methods — the API surface doesn't change.
Lazy Loading with Virtual Proxy
A virtual proxy stands in for an expensive object and loads it only when actually needed:
function createLazyProxy<T extends object>(loader: () => Promise<T>): T {
let target: T | null = null;
let loading: Promise<T> | null = null;
const pending: Array<{ resolve: (v: unknown) => void; prop: string | symbol }> = [];
const proxy = new Proxy({} as T, {
get(_target, property, receiver) {
if (target) {
return Reflect.get(target, property, receiver);
}
if (!loading) {
loading = loader().then(result => {
target = result;
pending.forEach(({ resolve, prop }) => {
resolve(Reflect.get(target!, prop));
});
pending.length = 0;
return result;
});
}
return new Promise(resolve => {
pending.push({ resolve, prop: property });
});
},
});
return proxy;
}
A simpler synchronous version for configuration objects:
function lazy<T extends object>(factory: () => T): T {
let instance: T | null = null;
return new Proxy({} as T, {
get(_target, property, receiver) {
if (!instance) instance = factory();
return Reflect.get(instance, property, receiver);
},
has(_target, property) {
if (!instance) instance = factory();
return Reflect.has(instance, property);
},
ownKeys() {
if (!instance) instance = factory();
return Reflect.ownKeys(instance);
},
});
}
const config = lazy(() => {
console.log("Loading heavy config...");
return {
apiUrl: "https://api.example.com",
features: computeFeatureFlags(),
theme: loadThemeConfig(),
};
});
// Config is NOT loaded yet — zero cost
// ...later when actually needed:
console.log(config.apiUrl); // "Loading heavy config..." then "https://api.example.com"
Production Scenario: Vue 3 Reactivity
Vue 3's entire reactivity system is built on Proxy. Here's a simplified version of how reactive() works:
type EffectFn = () => void;
let activeEffect: EffectFn | null = null;
const targetMap = new WeakMap<object, Map<string | symbol, Set<EffectFn>>>();
function track(target: object, key: string | symbol): void {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
function trigger(target: object, key: string | symbol): void {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects?.forEach(effect => effect());
}
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
const result = Reflect.get(target, key, receiver);
if (typeof result === "object" && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key);
}
return result;
},
});
}
function watchEffect(fn: EffectFn): void {
activeEffect = fn;
fn();
activeEffect = null;
}
const state = reactive({ count: 0, name: "Alice" });
watchEffect(() => {
console.log(`Count is: ${state.count}`);
});
// Immediately logs: "Count is: 0"
state.count = 1;
// Automatically logs: "Count is: 1"
state.name = "Bob";
// Nothing logs — the effect only reads count, not name
The magic: watchEffect runs the function, and during execution, every property read goes through the Proxy's get trap. The trap records which properties this effect depends on. When those properties change (via the set trap), only the relevant effects re-run. This is fine-grained reactivity — more precise than React's component-level re-rendering.
Proxy Performance Considerations
Proxies add overhead to every property access. V8 cannot inline or optimize through proxy traps the way it can with plain property access:
const plain = { x: 1 };
const proxied = new Proxy({ x: 1 }, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
});
// In a tight loop (1M iterations):
// plain.x → ~2ms
// proxied.x → ~15-25ms (7-12x slower)
This rarely matters for application code, but it's significant in hot paths like animation frames or tight data processing loops. Use Proxy for objects accessed at UI frequency (user actions, state changes), not for objects accessed at animation frequency (60fps rendering math).
| What developers do | What they should do |
|---|---|
| Using Proxy for performance-critical hot paths like animation calculations Proxy adds 7-12x overhead per property access compared to plain objects. At 60fps with thousands of operations per frame, this overhead becomes visible. Vue uses Proxy for reactive state (human speed) but plain objects for internal rendering math | Use Proxy for objects accessed at human-interaction speed — state, forms, configs. Use plain objects for animation math and tight loops |
| Wrapping every nested object in a Proxy eagerly on creation Eager wrapping creates proxy objects for every nested property upfront, even if they are never read. Lazy wrapping (as Vue 3 does) creates proxies on demand, saving memory and initialization time for large object trees | Create nested proxies lazily in the get trap — only wrap when actually accessed |
| Using target[property] inside proxy traps instead of Reflect methods target[property] in a get trap sets the getter's this to the raw target, bypassing the proxy for nested access. Reflect.get with the receiver ensures getters run with the proxy as this, making nested property access correctly trapped | Always use Reflect.get, Reflect.set, etc. inside traps |
Challenge
Build a createUndoableProxy that wraps any object and automatically tracks changes for undo/redo without requiring explicit command objects.
Try to solve it before peeking at the answer.
// Requirements:
// 1. Wraps any object with a Proxy
// 2. Every property set is automatically tracked
// 3. undo() reverts the last change
// 4. redo() reapplies the last undone change
// 5. getHistory() returns the list of changes
const state = createUndoableProxy({ count: 0, name: "Alice" });
state.value.count = 1;
state.value.count = 2;
state.value.name = "Bob";
state.undo(); // name → "Alice"
state.undo(); // count → 1
state.redo(); // count → 2
state.getHistory();
// [{ prop: "count", from: 0, to: 1 },
// { prop: "count", from: 1, to: 2 },
// { prop: "name", from: "Alice", to: "Bob" }]- 1ES6 Proxy intercepts fundamental object operations transparently — consumers never know the proxy exists
- 2Always use Reflect methods inside traps, never target[property] — Reflect correctly forwards the receiver for getters and setters
- 3Proxy adds 7-12x overhead per property access — use for human-speed interactions, not animation-frame hot paths
- 4Create nested proxies lazily in the get trap, not eagerly at construction — this is how Vue 3 keeps initialization fast
- 5Proxy enables powerful patterns: validation, reactivity (Vue 3), lazy loading, access control, and change tracking