ESC

Type to search the knowledge base.

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.

advanced6 min read
  • 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 pollute Array.prototype.
  • arr[i] = x and arr.length = 0 bypass method wrappers. That is the hole.
  • A Proxy set trap 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 on splice that changed nothing.
  • Mutating items after emit. Pass a copy, or freeze the event object, if listeners stash it.
  • Returning the inner array from toArray without slice. Callers then write indices on the real data.
  • Species / map on a subclass producing more observable arrays and firing events from helpers.
  • Proxy get wrapping every function including constructor, 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 guides