A production-ready TypeScript implementation of an observable object pattern using Proxy, featuring deep reactivity for nested objects, batch update capabilities to minimize notifications, and fully typed subscription callbacks.
type PropertyPath = (string | symbol)[];
interface ChangeEvent<T> {
path: PropertyPath;
oldValue: unknown;
newValue: unknown;
target: T;
}
type Subscriber<T> = (event: ChangeEvent<T>) => void;
interface Observable<T extends object> {
proxy: T;
subscribe: (callback: Subscriber<T>) => () => void;
batch: (fn: () => void) => void;
getSnapshot: () => T;
}
function createObservable<T extends object>(initialValue: T): Observable<T> {
const subscribers = new Set<Subscriber<T>>();
const proxyCache = new WeakMap<object, object>();
let isBatching = false;
let pendingChanges: ChangeEvent<T>[] = [];
function deepClone<V>(value: V): V {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(deepClone) as V;
}
const cloned: Record<string, unknown> = {};
for (const key of Object.keys(value)) {
cloned[key] = deepClone((value as Record<string, unknown>)[key]);
}
return cloned as V;
}
function notify(event: ChangeEvent<T>): void {
if (isBatching) {
pendingChanges.push(event);
return;
}
subscribers.forEach(callback => callback(event));
}
function createProxy<O extends object>(obj: O, path: PropertyPath = []): O {
if (proxyCache.has(obj)) {
return proxyCache.get(obj) as O;
}
const proxy = new Proxy(obj, {
get(target, prop, receiver): unknown {
const value = Reflect.get(target, prop, receiver);
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return createProxy(value as object, [...path, prop]);
}
if (Array.isArray(value)) {
return createArrayProxy(value, [...path, prop]);
}
return value;
},
set(target, prop, newValue, receiver): boolean {
const oldValue = Reflect.get(target, prop, receiver);
if (Object.is(oldValue, newValue)) {
return true;
}
const result = Reflect.set(target, prop, newValue, receiver);
if (result) {
notify({
path: [...path, prop],
oldValue,
newValue,
target: proxy as unknown as T
});
}
return result;
},
deleteProperty(target, prop): boolean {
const oldValue = Reflect.get(target, prop);
const result = Reflect.deleteProperty(target, prop);
if (result) {
notify({
path: [...path, prop],
oldValue,
newValue: undefined,
target: proxy as unknown as T
});
}
return result;
}
});
proxyCache.set(obj, proxy);
return proxy;
}
function createArrayProxy<A extends unknown[]>(arr: A, path: PropertyPath): A {
const cacheKey = arr as object;
if (proxyCache.has(cacheKey)) {
return proxyCache.get(cacheKey) as A;
}
const proxy = new Proxy(arr, {
get(target, prop, receiver): unknown {
const value = Reflect.get(target, prop, receiver);
if (typeof prop === 'string' && !isNaN(Number(prop))) {
if (value !== null && typeof value === 'object') {
return createProxy(value as object, [...path, prop]);
}
}
if (typeof value === 'function') {
const mutatingMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
if (mutatingMethods.includes(prop as string)) {
return function(this: unknown, ...args: unknown[]): unknown {
const oldArray = [...target];
const result = (value as Function).apply(target, args);
notify({
path,
oldValue: oldArray,
newValue: [...target],
target: rootProxy as T
});
return result;
};
}
}
return value;
},
set(target, prop, newValue, receiver): boolean {
const oldValue = Reflect.get(target, prop, receiver);
if (Object.is(oldValue, newValue)) {
return true;
}
const result = Reflect.set(target, prop, newValue, receiver);
if (result && typeof prop === 'string' && !isNaN(Number(prop))) {
notify({
path: [...path, prop],
oldValue,
newValue,
target: rootProxy as T
});
}
return result;
}
});
proxyCache.set(cacheKey, proxy);
return proxy;
}
const rootProxy = createProxy(initialValue);
function subscribe(callback: Subscriber<T>): () => void {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function batch(fn: () => void): void {
isBatching = true;
pendingChanges = [];
try {
fn();
} finally {
isBatching = false;
if (pendingChanges.length > 0) {
const batchedEvent: ChangeEvent<T> = {
path: ['__batch__'],
oldValue: pendingChanges.map(c => c.oldValue),
newValue: pendingChanges.map(c => c.newValue),
target: rootProxy
};
subscribers.forEach(callback => {
pendingChanges.forEach(event => callback(event));
});
}
pendingChanges = [];
}
}
function getSnapshot(): T {
return deepClone(initialValue);
}
return {
proxy: rootProxy,
subscribe,
batch,
getSnapshot
};
}
// Usage Example
interface User {
name: string;
age: number;
address: {
city: string;
country: string;
};
hobbies: string[];
}
const { proxy: user, subscribe, batch, getSnapshot } = createObservable<User>({
name: 'Alice',
age: 30,
address: {
city: 'New York',
country: 'USA'
},
hobbies: ['reading', 'coding']
});
const unsubscribe = subscribe((event) => {
console.log(`Property changed at path: ${event.path.join('.')}`);
console.log(`Old value: ${JSON.stringify(event.oldValue)}`);
console.log(`New value: ${JSON.stringify(event.newValue)}`);
});
user.name = 'Bob';
user.address.city = 'Los Angeles';
user.hobbies.push('gaming');
batch(() => {
user.age = 31;
user.address.country = 'Canada';
});
const snapshot = getSnapshot();
console.log('Snapshot:', snapshot);
unsubscribe();This implementation creates a fully reactive observable object system using JavaScript's Proxy API. The core concept revolves around intercepting property access and modifications through proxy traps, enabling automatic notification of subscribers whenever state changes occur.
The createObservable function returns four key components: a proxied version of your object that tracks all changes, a subscribe function for registering change listeners, a batch function for grouping multiple updates, and a getSnapshot function for obtaining an immutable copy of the current state. The proxy system uses a WeakMap cache to ensure the same proxy instance is returned for identical objects, preventing memory leaks and maintaining referential consistency.
Nested object support is achieved through lazy proxy creation in the get trap. When you access a nested property that's an object, the system wraps it in its own proxy on-demand, tracking its path from the root. This approach, often called "deep reactivity," means user.address.city = 'Boston' will correctly notify subscribers with the full path ['address', 'city']. Arrays receive special handling through a dedicated createArrayProxy function that intercepts mutating methods like push, pop, and splice, ensuring these operations also trigger notifications.
The batch update system uses a simple flag-based approach where changes during a batch operation are collected rather than immediately dispatched. Once the batch function completes (even if an error occurs, thanks to the try-finally block), all pending changes are delivered to subscribers. This is crucial for performance when making multiple related changes, as it prevents unnecessary re-renders or computations.
Use this pattern when building state management systems, form libraries, or any scenario requiring reactive data binding. However, be mindful of its limitations: very deep object hierarchies may impact performance, and circular references require additional handling. For extremely performance-critical applications with frequent updates, consider more specialized solutions like signals or compiled reactivity systems.