zoo.do: an 11.2 KB proxy that treats many objects as one
zoo.do is a single-file 11.2 KB proxy library that wraps any number of objects so one property read gathers every value and one assignment writes to them all.
Somewhere in your codebase there is a loop that disables some buttons, and next to it a second loop that remembers what was disabled before, because restoring is never as simple as enabling. zoo.do exists for exactly that code. It wraps any number of objects (DOM elements, plain objects, whatever you have) behind one proxy, so that one property read gathers every value and one assignment writes them all. The snapshot problem dissolves: a read IS the snapshot, and assigning it back restores every target to its own value.
Every example in this article runs live on this page, against the same frozen copy of the library inlined at the top of this post. The dark panes are real output, produced by the exact code shown above them. (Feed readers strip scripts; the prose states everything the demos show.)
const cfg = { id: 'cfg', disabled: false }; // a plain object, riding along
const X = $(document.querySelectorAll('#zd1 .zd-stage :is(button,input)'), cfg);
const before = X.disabled; // identity-keyed snapshot of five mixed states
X.disabled = true; // one assignment, five targets
console.log('frozen: ', ...X.disabled);
await new Promise(r => setTimeout(r, 600));
X.disabled = before; // the same assignment, run in reverse
console.log('restored:', ...X.disabled);
What zoo.do is
One file, two globals. The library defines $ and $$, weighs 11.2 KB as served (about 3.6 KB gzipped) across 189 lines, and lives at zoo.do/.js, where it is published with an open CORS header straight from the playground page it was built in. There is no package, no build step, and no framework underneath: it is a JavaScript Proxy with strong opinions about collections. A frozen copy is inlined as the first script card of this very post, byte for byte identical to the served file after newline normalization, and it is what every demo here runs against; View Source shows you the whole thing.
The name is the mental model. A zoo is many different animals under one management: you do not ask the zebra and the tortoise to share an API before you can feed them. $(...) accepts DOM elements next to plain objects next to class instances, and treats them identically, because property access is the one interface every JavaScript object already has.
Reads gather, writes scatter
Everything in zoo.do follows from one symmetry. A write to the proxy fans out: X.disabled = true performs that assignment on every target, whether it is a button or a config object. A read from the proxy fans in: X.value reads every target and hands you the results as a chainable map, keyed by the targets themselves.
The gathered side has three doors out of the proxy world. Spreading a read (...X.value) yields [target, value] pairs. The .$ property hands you the underlying real collection: a $.Set of targets on the collection itself, a $.Map of entries on any property read. And coercion votes: +X.width or `${X.fw}` produces the single shared value when every target agrees; on disagreement a template literal gives an empty string and a numeric coercion gives $.NO_CONSENSUS, a recognizable sentinel with its own $.isNoConsensus test. Agreement is checked by identity (SameValueZero, so NaN agrees with NaN), and targets whose value is undefined or a captured error abstain rather than veto.
One habit to build early: a read is always a truthy proxy object, even when every gathered value is false, so if (X.disabled) tests the wrong thing. Coerce on purpose (+X.disabled), or look at the real values through .$. Iteration, on the other hand, simply works: spreading the collection ([...X]) yields the targets, and spreading any property read yields the [target, value] pairs.
Assign a function, transform every target
The right-hand side of an assignment is a little language. A plain value broadcasts. A function wrapped in $() becomes a per-target transform: zoo.do reads every target first, then runs your function once per target with (value, target, allPreReadValues), then writes every result back. Reads, transforms, and writes happen in three strict phases, so a transform can safely look at the whole picture through its third argument.
const reviews = [
{ id: 'r1', score: 85 }, { id: 'r2', score: 92 }, { id: 'r3', score: 78 },
];
const all = $(reviews);
all.score = $((v, target) => target.id === 'r2' ? v + 10 : v + 5);
console.log(...all.score);
Snapshot now, restore later
The third right-hand shape is the quiet star of this library: assigning a Map. Each collection member that appears as a key in the Map receives its own value (call it unicast, where a plain value is broadcast); members missing from the Map keep what they have; entries whose value is an error are skipped. Now look back at the first demo on this page. const before = X.disabled read a map keyed by the five targets; X.disabled = before routed each saved value back to its own object. That is undo, drain-and-rollback, and optimistic UI in one move, and because the keys are the objects themselves rather than array positions, the restore survives reordering and refiltering.
Two refinements round it out. Since reads allocate a fresh map each time, a held read never goes stale by accident: it is a snapshot by construction. And when you hold one in a const, the pseudo-property .$V writes through it anyway (a value, a transform, or another Map), then refreshes the held map from the targets.
Errors are values
Every per-target operation in zoo.do runs inside its own try/catch. A getter that throws, a missing intermediate object, a method that explodes for one target: the failure is caught, stored as that target's value, and the operation continues for everyone else. Once an error enters a lane it rides every later chain step unchanged, by reference, so the same Error object you fish out at the end is the one minted where things went wrong.
This is why chains through optional structure need no guards at all. Where vanilla JavaScript reaches for ?. and still throws on a method call two steps later, a zoo.do chain simply keeps going, and you sort survivors from casualties at the end with $.isErr.
const ok = { id: 'ok', user: { plan: { price: 9 } } };
const broken = { id: 'broken' }; // has no .user at all
const price = $(ok, broken).user.plan.price; // nothing throws, ever
console.log(...price);
console.log('usable:', price.$.filter(v => !$.isErr(v)));
Live collections re-ask the question
$() answers "what was there when I asked". $$() answers "what is there right now", every single time. A live collection runs zero queries when you create it; each read, write, or .$ access re-runs the selector or generator from scratch. Membership becomes a predicate rather than a list you must maintain.
Selectors are the obvious use, but the generator form is the general one: any rule you can express as "yield the things that qualify" becomes a permanent, always-current handle.
const tasks = new Map([
['a', { id: 'a', status: 'open' }],
['b', { id: 'b', status: 'done' }],
['c', { id: 'c', status: 'open' }],
]);
const open = $$(function* () { for (const t of tasks.values()) if (t.status === 'open') yield t; });
open.status = 'in-progress'; // sweeps a and c
tasks.get('b').status = 'open'; // b reopens after that sweep
open.status = $(s => s + '!'); // same handle, next sweep: only b matches now
console.log(...$(tasks).status);
Async that keeps its keys
Call an async method on a collection and you get a map of promises, keyed by the targets. The usual next step, Promise.all, would flatten that into an anonymous array and reject wholesale on the first failure. zoo.do extends the built-in instead: Promise.all.map awaits a Map of promises and returns the same keys with settled values, capturing rejections as values by default (catch: false restores fail-fast, and a custom catch(reason, key, map) can substitute fallbacks per key).
No index bookkeeping, no zip step, no lost association between who you asked and what they said.
const us = { name: 'us-east-1', ping: () => new Promise(r => setTimeout(r, 30, 12)) }; // settles last
const eu = { name: 'eu-west-1', ping: async () => 31 };
const ap = { name: 'ap-south-1', ping: async () => { throw new Error('socket timeout'); } };
console.log('ping ms:', await Promise.all.map($(us, eu, ap).ping()));
Nine enclosures
The mechanisms above compose, and the compositions are where the library earns its keep. What follows is a tour through nine small, real scenarios from different corners of programming. Every one was run before it was written about, and each demonstrates a distinct combination of the mechanisms above.
Operations
Start with the strongest composition in the whole post.
const auth = { name: 'auth', lastGood: 'v1', healthz: async () => 'v2' };
const cart = { name: 'cart', lastGood: 'v1', healthz: async () => { throw new Error('ECONNREFUSED'); } };
const mail = { name: 'mail', lastGood: 'v1', healthz: async () => 'v2' };
const fleet = $(auth, cart, mail);
fleet.lastGood = await Promise.all.map(fleet.healthz());
console.log(...fleet.lastGood);
That card is the whole library in one line: a fanned-out call, a key-preserving await, and a unicast write-back composing so that the crashed service keeps its last known good value without a single conditional. The next two scenarios lean on the snapshot Map.
const lb1 = { id: 'lb-fra-1', weight: 100 };
const lb2 = { id: 'lb-fra-2', weight: 80 };
const lb3 = { id: 'lb-fra-3', weight: 20 }; // already half-drained
const pool = $(lb1, lb2, lb3);
const saved = pool.weight.$; // snapshot: Map of device to weight
pool.weight = 0; // drain everything for the risky change
console.log('drained:', ...pool.weight);
pool.weight = saved; // rollback: each device gets its own value back
console.log('restored:', ...pool.weight);
State and simulation
The same shape powers an undo stack: push a read, mutate, pop into an assignment.
const bands = [{ id: 'low', gain: 2 }, { id: 'mid', gain: 5 }, { id: 'high', gain: 9 }];
const eq = $(bands), undo = [];
undo.push(eq.gain.$); eq.gain = 0; // snapshot, then broadcast: flat preset
undo.push(eq.gain.$); eq.gain = $(g => g + 3); // snapshot, then transform: up 3 dB
console.log('now :', ...eq.gain);
eq.gain = undo.pop();
console.log('undo 1:', ...eq.gain);
eq.gain = undo.pop();
console.log('undo 2:', ...eq.gain);
Games and simulations get the most out of live membership, because their populations change every tick.
const units = [{ id: 'knight', hp: 80 }, { id: 'archer', hp: 12 }, { id: 'mage', hp: 60 }];
const critical = $$(function* () { for (const u of units) if (u.hp < 20) yield u; });
critical.hp = 25; // triage tick: archer is the only critical unit right now
units[0].hp = 8; units.push({ id: 'recruit', hp: 5 }); // knight mauled, recruit spawns hurt
critical.hp = 25; // same line, next tick: heals the knight and the recruit instead
console.log(...$(units).hp);
console.log('still critical:', critical.$.size);
Configuration work is where the deletion broadcast and the in operator do their best work.
const mk = id => ({ id, flags: { betaCheckout: true, darkMode: true } });
const dev = mk('dev'), staging = mk('staging'), prod = mk('prod');
Object.freeze(prod.flags); // prod is in a change freeze
const envs = $(dev, staging, prod);
delete envs.flags.betaCheckout; // retire the flag fleet-wide
console.log('anyone still pinning it?', 'betaCheckout' in envs.flags);
console.log(...envs.flags.betaCheckout);
Data and the DOM
Data wrangling benefits from the error discipline: a batch where one bad record cannot sink the rest.
const feed = [
'{"id":"A1","qty":2}',
'{"id":"B7","qty":',
'{"id":"C3","qty":5}',
];
console.log(new $.Set(feed).map(JSON.parse).$);
And zoo.do's Map.invert extension turns a property read into a duplicate report in one expression.
const users = [
{ id: 1, email: 'ana@example.com' },
{ id: 2, email: 'bo@example.com' },
{ id: 3, email: 'ana@example.com' },
{ id: 4, email: 'cy@example.com' },
];
const dupes = Map.invert($(users).email).filter(Array.isArray);
console.log(...dupes);
Back in the DOM, the browser itself can be the predicate: :invalid is a live rule the engine maintains for free.
const invalid = $$('#zd14 input:invalid'); // live: re-queries on every use
invalid.ariaInvalid = 'true';
console.log('invalid now:', ...invalid);
document.querySelector('#zd14 input').value = 'Ada'; // the user types a name
console.log('after typing:', ...invalid);
console.log('stale flag stays:', document.querySelector('#zd14 input').ariaInvalid);
Finally, the consensus vote turns fleet drift into a one-line question.
const s1 = { id: 'sensor-a', fw: '4.2.0' };
const s2 = { id: 'sensor-b', fw: '4.1.9' };
const s3 = { id: 'sensor-c', fw: '4.2.0' };
const s4 = { id: 'sensor-d' }; // never reported a version
const fleet = $(s1, s2, s3, s4);
console.log(`consensus: [${fleet.fw}]`); // empty: the fleet disagrees
console.log('lagging:', ...[...fleet.fw].filter(([, v]) => v !== '4.2.0'));
s2.fw = '4.2.0';
console.log(`consensus: [${fleet.fw}]`); // silent sensor-d does not block the vote
The dollar sign, then and now
The $ carries history, and zoo.do wears it on purpose. jQuery's dollar made "many elements, one statement" the most copied idea in front-end history: $('.item').hide() needed no loop, and an entire generation learned the web on that grammar. zoo.do is a deliberate generalization of that idea past the DOM: many anything, one statement.
Credit where it is due, and it is due in a specific place: jQuery's setters take functions too. $('input').val((i, v) => v.toUpperCase()) transforms each field individually, and it has worked since long before this library existed. For that one task the two are exactly as short as each other:
jQuery('#f input').val((i, v) => v.toUpperCase()); // jQuery, per element
$('#f input').value = $(v => v.toUpperCase()); // zoo.do, per targetThe differences start one step away from that happy path, and we verified each of these against jQuery 3.7.1 and Lodash 4.17.21 before writing them down. First: jQuery getters answer for one element. .val(), .prop(), .css() all return the first match's value, so the getter gives you no whole-set read, and a keyed snapshot is your own bookkeeping. The idiomatic jQuery snapshot is index-keyed (.map((i, el) => el.disabled).get() restored via .prop('disabled', i => saved[i])), and it works, but it breaks the moment the set is reordered or refiltered between save and restore; zoo.do's snapshot is keyed by the elements themselves. Second: error isolation. Throw inside a jQuery .each callback and the iteration stops dead; the remaining elements are never visited. In zoo.do the exception becomes that one target's value and the rest of the herd keeps moving. Third: liveness. A jQuery set is frozen at query time, so elements added later never join it, and even the .live() name for delegated events went in 1.9 (delegation survives as .on() with a selector). zoo.do makes liveness itself a constructor.
Lodash plays a different position, so the honest comparison is narrower. _.set(obj, 'a.b.c', 1) writes one nested path on one object beautifully, and it will even create the missing intermediate objects, which zoo.do deliberately will not (a zoo.do nested write silently skips targets that lack the path). But fanning that write across ten objects is still your loop to write, reads do not come back keyed, and lodash ships no Promise combinators at all: the keyed-async problem is simply out of its scope.
targets.forEach(t => _.set(t, 'theme.accent', accent)); // lodash: loop is yours
$(targets).theme.accent = accent; // zoo.do: write is the loopSize, measured from the official CDNs on the day of writing and gzipped with identical flags: jQuery 3.7.1 is 87.5 KB minified (30.2 KB gzipped), its slim build 70.3 KB (23.9 KB), lodash 4.17.21 is 73.0 KB minified (25.7 KB), and zoo.do is 11.2 KB of unminified, commented source (3.6 KB gzipped). That gap measures scope, not skill: jQuery normalizes events, effects, and a decade of browser disagreements; lodash is a general toolkit of three hundred functions. Neither ever promised transparent property broadcast over arbitrary objects. That is the one job zoo.do does, and at roughly an eighth of jQuery's weight it does it with room to spare.
Honest edges
A library this magical owes you a map of where the magic stops. The first draft of this article listed ten edges here. Writing them down turned six of them from edges into bug reports, so we fixed the library instead: the fixes shipped to zoo.do/.js, and the copy this page runs was re-frozen from the result. Spreading a collection now yields its targets instead of a silent nothing. Object.prototype.toString no longer crashes on the proxies (a genuine invariant bug our probing surfaced). null and undefined are both dropped at the door, as arguments and inside iterables. $(generatorFn) now takes a static snapshot of the yields, the natural sibling of live $$(generatorFn). A broadcast delete never throws again, in any strictness: the per-target truth lands in $.lastDelete and failures report through $.onWriteError. And compound operators grew real semantics, which deserves its own paragraph.
X.tries-- used to be this library's sharpest edge: the operator coerces the read to a single primitive before the proxy can fan anything out, so disagreeing targets collapsed to NaN and that NaN was broadcast back over everyone, silently. The fix is the sentinel from the coercion rule above. When a numeric coercion finds disagreement it returns $.NO_CONSENSUS and arms a one-shot record of every target's old value; if the next write to that property in the same synchronous run lands within one step of the sentinel, the library recovers the per-target delta. So X.tries--, X.tries++, and X.tries -= 1 now genuinely work on mixed values. Anything whose operand cannot be reconstructed (+=, *=, larger steps) is blocked instead: state untouched, $.onWriteError fired per target.
const a = { id: 'a', tries: 3 }, b = { id: 'b', tries: 5 };
const X = $(a, b);
X.tries--; // mixed values: the sentinel recovers the step
console.log('after X.tries--: ', ...X.tries);
const prev = $.onWriteError;
$.onWriteError = (t, p, v, e) => console.log(t, e);
X.tries *= 2; // operand unrecoverable: blocked, loudly
console.log('after X.tries *= 2:', ...X.tries);
$.onWriteError = prev;
What remains is what stays by choice. Reads are always truthy, as covered above; a proxy is an object and objects cannot be falsy, so that one is physics, not policy. Arguments flatten: $(users) proxies the users inside the array, and operating on arrays themselves takes one more pair of brackets, $([a, b]). The unanimity vote compares by identity, so two deep-equal objects do not count as agreement, by design.
Globals deserve their own disclosure. zoo.do assigns window.$ and window.$$ unconditionally, with no noConflict escape: load order decides, and it will shadow the DevTools console helpers of the same names. It also extends built-ins, guardedly (Map.invert, Promise.all.map, Promise.allSettled.map, each entry point ??=-guarded so existing definitions win), and one piece of its configuration is mutable global state: assigning Promise.all.map.catch = false flips the default for every later call on the page. This very article defines $ on the page you are reading; we checked that nothing else here wanted the name first.
The API on one page
Everything above, in reference form. The $.document hook in the escape-hatch table is how this article's examples were verified outside a browser before they ever ran in one.
Entry points
| Call | Returns |
|---|---|
$(a, b, ...) | A static collection proxy over the targets. Iterable arguments are flattened into their elements, so $(arrayOfUsers) proxies the users; wrap arrays in another iterable ($([a, b])) to proxy the arrays themselves. Nullish values are dropped, as arguments and inside iterables. |
$('selector') | A static snapshot of querySelectorAll taken at call time (exactly one query, never repeated). |
$(fn) | A $.Function transform for use on the right side of an assignment. A single sync generator function does not land here: $(function* () {...}) takes a one-time static snapshot of the yields, the static sibling of $$(genFn). |
$$('selector') | A live collection: zero queries at creation, one fresh querySelectorAll per operation. |
$$(function* () {...}) | A live collection whose membership is the generator's yield set, re-run on every operation. |
Operations on a collection X (and on every chained property proxy)
| Operation | Semantics |
|---|---|
X.prop | Reads every target; returns a chainable map proxy keyed by the targets. Reads that throw store the Error as that target's value; chains never throw. |
...X.prop | Spreading a property read yields [target, value] pairs. Spreading the collection itself ([...X]) yields the targets; live collections re-query for each iteration. Both proxies answer Object.prototype.toString as [object $.Set] / [object $.Map]. |
X.prop = value | Broadcast write to every target (failed writes call $.onWriteError). |
X.prop = $(fn) | Per-target transform: reads all, then fn(value, target, mapOfPreReadValues) for all, then writes all. |
X.prop = someMap | Unicast: each collection member that appears as a key receives its own value; members not in the map keep their value; entries whose value is an Error are skipped. (One level deeper, X.a.b = map writes to the map's keys directly.) |
X.method(args) | Calls per target with this bound to the value's owner; non-function values pass through unchanged; results come back as a map proxy. |
'p' in X | True if any target has the property (own or inherited); Error and null values are skipped on map proxies. |
delete X.prop | Deletes from every target and never throws, whatever the strictness. Per-target outcomes land in $.lastDelete (a $.Map of target to boolean); failed deletions fire $.onWriteError; poison keys are refused with all-false outcomes. |
+X.prop, `${X.prop}` | Unanimity coercion: the shared value if every target agrees (identity-strict; undefined and Error values do not vote). On disagreement a template literal gives '' and a number-hint coercion gives the $.NO_CONSENSUS sentinel, which also arms compound-operator recovery: X.p--, X.p++, and X.p -= 1 apply per target with each target's own previous value, while unrecoverable forms (+=, *=, larger steps) are blocked through $.onWriteError with state untouched. |
Escape hatches and hooks
| Hook | Meaning |
|---|---|
X.$ | On a collection: a $.Set of the targets (on static collections its add/delete/clear write through to the backing snapshot). On a property proxy: the underlying $.Map (the same object every time). |
X.prop.$V = v | Writes through a const-held property map: accepts a value, a $(fn), or a Map, then refreshes the held map from the targets. |
$.onWriteError | (target, prop, value, error) => called for every failed write instead of throwing. |
$.lastDelete | The per-target outcome map of the most recent broadcast delete. |
$.NO_CONSENSUS / $.isNoConsensus(v) | The no-consensus sentinel a number-hint coercion returns on disagreement, and its test. |
$.document | Swap in a fake { querySelectorAll } for tests; selector collections resolve against it, no browser needed. |
$.read / $.trySet / $.isErr ... | The small helper kit the library itself is built from, exposed on $. |
Classes and Promise extensions
| API | Meaning |
|---|---|
$.Map | Map plus map(fn), filter(fn) (keys preserved, order preserved) and invert(). |
$.Set | Set plus map(fn), which returns a proxy map keyed by the elements and captures per-element throws as Error values. |
Map.invert(m, { vK }) | Swaps keys and values; colliding keys promote the value to an array at the first duplicate's position. Returns the input's constructor; works directly on proxy maps. |
Promise.all.map(m, { catch, [Promise.all.map.KV]: kv }) | Resolves a Map<key, Promise> (or a proxy map) preserving keys and order. Rejections become values by default; catch: false restores fail-fast; a custom catch(reason, key, map) substitutes a value. |
Promise.allSettled.map(m) | Same shape with { status, value | reason } records per key. |
Scope: current evergreen browsers and runtimes with Proxy (which cannot be polyfilled). The whole library is the script card at the top of this article; the canonical copy is zoo.do/.js.
Closing time at the zoo
Proxies have been in every browser for nearly a decade, and mostly we used them for frameworks' internals: change detection, reactivity, dev-mode warnings. zoo.do uses them for something plainer and stranger: making "do this to all of them" a sentence of JavaScript rather than a paragraph. One read gathers, one write scatters, errors ride along, and membership can be a question you keep asking.
The gates stay open: the whole library is the script card near the top of this page, and the canonical copy lives at zoo.do/.js. Bring any objects you like; the enclosure fits them all.