Arrays That Emit Change Events
Wrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- javascript
- proxy
- events
- interview
Interview prompts for arrays that emit change events usually start with wrapping push. A teammate then writes arr[0] = next and arr.length = 0. No event fires. The screen is stale, the test is green if it only used push, and you now have a philosophical argument about whether an array is an object with methods or a bag of index slots.
It is both. Mutating methods (push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin) are one door. Index assignment and the length setter are another. A wrapper that only patches methods will always have that hole. A Proxy can trap set. Subclassing Array does not close the hole. Patching Array.prototype patches every array in the process.
Prefer a small object that owns the data and the methods. Say the index-assignment hole out loud even if you do not implement a Proxy.
Key takeaways
- Wrap
push,pop,splice(and friends) on your object. Do not polluteArray.prototype.arr[i] = xandarr.length = 0bypass method wrappers. That is the hole.- A
Proxysettrap can see index writes. A subclass cannot, unless you also proxy it.- Snapshot listeners before emit so a subscriber can unsubscribe without skipping the next one.
Patch the mutating methods, or trap them
Start with a standalone wrapper, not a prototype assignment. Interview convention sometimes wants Array.prototype, and that mutates a shared builtin. Say so, then don’t.
function listenArray(initial = []) {
const data = initial.slice();
const listeners = new Set();
function emit(change) {
for (const fn of [...listeners]) fn(change);
}
return {
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
push(...added) {
const index = data.length;
const n = data.push(...added);
if (added.length) emit({ type: 'add', index, items: added });
return n;
},
pop() {
if (data.length === 0) return undefined;
const index = data.length - 1;
const item = data.pop();
emit({ type: 'remove', index, items: [item] });
return item;
},
splice(start, deleteCount = data.length - start, ...added) {
const removed = data.splice(start, deleteCount, ...added);
if (removed.length || added.length) {
emit({ type: 'splice', index: start, removed, added });
}
return removed;
},
at(i) {
return data.at(i);
},
toArray() {
return data.slice();
},
get length() {
return data.length;
},
};
}
const todos = listenArray(['write']);
const off = todos.onChange((e) => console.log(e.type, e.items ?? e.added));
todos.push('review'); // add
todos.pop(); // remove
off();
Copy [...listeners] before iterating. A handler that unsubscribes (or subscribes another handler) must not skip a neighbor. Same rule as an event emitter.
push with zero args does not change the list; skip the event. Empty pop returns undefined and should not pretend a remove happened.
This object is not an array. Array.isArray(todos) is false. todos.map does not exist unless you add it. That is a feature: callers cannot todos[0] = x because there is no index slot. If the prompt requires Array.isArray and arr[0], you are in Proxy or subclass territory.
The index-assignment hole
| Mutation | Method wrapper sees it | Proxy set can see it |
|---|---|---|
push / pop / splice |
yes | yes, if you wrap the method or trap set on length |
arr[i] = value |
no | yes |
arr.length = 0 |
no | yes |
arr[arr.length] = value (append) |
no | yes |
delete arr[i] |
no | deleteProperty trap |
sort / reverse / fill |
only if wrapped | via method or many sets |
const arr = ['a', 'b'];
arr.push = function (...items) { /* notify */ return Array.prototype.push.apply(this, items); };
arr[0] = 'z'; // silent
arr.length = 0; // silent
arr[0] = 'back'; // silent append
If you return a real Array with a few methods overwritten, this is your bug. Mention it before the interviewer does.
A Proxy closes the hole by trapping set. Forward with Reflect.set so length and accessors stay consistent. See Proxy and Reflect.
function proxyListenArray(initial = []) {
const data = initial.slice();
const listeners = new Set();
const emit = (change) => {
for (const fn of [...listeners]) fn(change);
};
const mutating = new Set([
'push', 'pop', 'shift', 'unshift', 'splice',
'sort', 'reverse', 'fill', 'copyWithin',
]);
const proxy = new Proxy(data, {
set(target, prop, value, receiver) {
const oldLen = target.length;
const ok = Reflect.set(target, prop, value, receiver);
if (!ok) return false;
if (prop === 'length') {
emit({ type: 'length', from: oldLen, to: target.length });
return true;
}
if (typeof prop === 'string' && String(Number(prop)) === prop) {
emit({ type: 'index', index: Number(prop), value });
}
return true;
},
get(target, prop, receiver) {
if (prop === 'onChange') {
return (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
}
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function' && mutating.has(prop)) {
return function (...args) {
const before = target.slice();
const result = value.apply(target, args);
emit({ type: prop, before, after: target.slice(), args });
return result;
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
return proxy;
}
Binding methods to target avoids this pointing at the proxy in ways that recurse through get. It is easy to infinite-loop a get trap. Keep traps thin.
Index set plus a mutating method will often emit twice (push writes indices and length). Either document that, or ignore set while a method flag is true. Interviewers like that sentence more than a perfect coalescer.
Subclass vs wrapper vs Proxy
class ObservableArray extends Array {
push(...items) {
const n = super.push(...items);
this.onPush?.(items);
return n;
}
}
arr[0] = x still bypasses push. map and filter may return an ObservableArray via species, which surprises callers. Array.isArray is true, which some prompts want.
| Shape | Array.isArray |
Index write notifies | Prototype pollution | Use |
|---|---|---|---|---|
| Method wrapper object | false | no (no indices) | no | Default interview answer |
| Methods on a real array | true | no | no if instance-only | Looks like an array, hole remains |
extends Array |
true | no | no | OOP flavor, same hole |
Proxy |
true | yes | no | When they ask for index writes |
Array.prototype.push = ... |
true | no | yes | Do not |
DOM custom events are a different channel: useful if the array is owned by a widget and listeners sit on an element. Do not dispatch from Array.prototype.
Footguns
- Notifying on
push()with no args, or onsplicethat changed nothing. - Mutating
itemsafter emit. Pass a copy, or freeze the event object, if listeners stash it. - Returning the inner array from
toArraywithoutslice. Callers then write indices on the real data. - Species /
mapon a subclass producing more observable arrays and firing events from helpers. - Proxy
getwrapping every function includingconstructor,toString, and iterators, then blowing the stack.
Interview answer (out loud)
“I would wrap push, pop, and splice on an object I own and emit add/remove events to a Set of listeners. I would not patch Array.prototype. Index assignment and setting length bypass method wrappers, so if the prompt requires arr[i] = x to notify, I use a Proxy set trap and Reflect. A subclass of Array still has that hole. I snapshot listeners before emit, and I copy on the way out so callers cannot mutate my storage through toArray.”
Further reading
Related on this site
- Proxy and Reflect
- Event emitter pattern
- Custom events
- Array.map polyfill
- Classes in JavaScript
- Prototypal inheritance
Related guides
- EventEmitter ImplementationBuild on, off, once, and emit. Copy the listener list before emit so a handler that unsubscribes cannot skip the next listener.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.