skip to content

You are building a reactive state layer on top of JavaScript Proxy. How do you decide between wrapping the object graph deeply and wrapping only the top level, and what does the deep option force you to build?

level: principalimportance: nice to knowfreq 22%

answer

  1. who mutates, and at what depth
  2. wrap lazily on read, not eagerly
  3. WeakMap cache keeps === stable
  4. skip class instances, collections, DOM nodes
  5. raw hatch for cloning and workers

basics

~20 s

Deep wrapping observes nested mutations without ceremony but requires lazy per-access wrapping, a target-to-proxy cache for stable identity, special handling for collections and class instances, and an unwrap hatch. Shallow wrapping is cheap and predictable but pushes explicit replacement onto callers.

solid answer

~50 s

Decide by who writes the mutations. If application code will assign deep into the graph — `state.user.address.city = x` — deep wrapping is the only ergonomic option, and it means wrapping nested objects lazily on read, not eagerly at creation. That immediately forces four pieces of machinery: a `WeakMap` from target to proxy so repeated reads return the *same* proxy and `===` stays stable, the reverse map so you can unwrap for serialization and worker transfer, a check that you never wrap a proxy again, and a skip list for objects a Proxy cannot transparently wrap — class instances with private fields, `Map`/`Set` and other internal-slot built-ins, DOM nodes, typed arrays. If instead callers replace whole subtrees immutably, shallow wrapping is far less machinery, far less overhead and much easier to debug. I would default to shallow and only go deep when the mutation style demands it.

go deeper

for a junior

Know the two shapes: a shallow wrapper observes only top-level keys, while a deep one also hands back wrapped objects for nested values so writes at any depth are seen.

for a middle

Explain lazy wrapping — the nested proxy is created when the property is first read, not at construction — and why that beats walking the whole graph up front.

for a senior

Show the machinery you would actually build: a WeakMap target-to-proxy cache for stable identity, an unwrap function for cloning and workers, and a skip list for objects that a Proxy cannot wrap transparently.

for a principal

Own the tradeoff and the boundary. Justify the default (shallow unless callers mutate in place at depth), decide which data never enters the graph, and make the reactive/raw seam an explicit, documented part of the API.

## The decision, stated properly The question is not 'deep or shallow' in the abstract; it is *what mutation style do the callers use, and what identity guarantees do they need*. Deep wrapping buys ergonomics — write to any nesting level and it is observed. Shallow wrapping buys predictability — exactly one object is special, and everything reachable from it is ordinary data. If the surrounding codebase already replaces subtrees immutably (a store that assigns a fresh object at the top), deep wrapping is machinery bought for nothing. If callers mutate in place at arbitrary depth, shallow wrapping means every such write silently fails to notify, which is the worst possible failure mode: correct-looking code that does not update. ## What deep wrapping forces you to build **Lazy wrapping, never eager.** Walking the graph at creation is O(nodes) up front and wraps subtrees nobody ever reads. Instead the `get` trap wraps nested objects on first access: ```js const proxyCache = new WeakMap(); // target -> proxy const rawCache = new WeakMap(); // proxy -> target function reactive(target) { if (!isPlainish(target)) return target; // skip list, see below if (rawCache.has(target)) return target; // already a proxy const existing = proxyCache.get(target); if (existing) return existing; const proxy = new Proxy(target, { get(t, key, receiver) { track(t, key); return reactive(Reflect.get(t, key, receiver)); }, set(t, key, value, receiver) { const ok = Reflect.set(t, key, toRaw(value), receiver); trigger(t, key); return ok; } }); proxyCache.set(target, proxy); rawCache.set(proxy, target); return proxy; } function toRaw(v) { return rawCache.get(v) ?? v; } ``` **A target-to-proxy cache.** Without it, two reads of `state.user` return two different proxies, so `state.user === state.user` is `false`. That breaks memoisation, dependency comparison, keyed list diffing and every `Set`-based dedup downstream. `WeakMap` is the right structure because it does not keep dead targets alive. **A raw hatch and a normalization rule.** You need `toRaw` for serialization, `structuredClone`, worker transfer and object-keyed caches, all of which reject or mis-key a proxy. You also need a rule about what happens when a proxy is *written into* the graph: storing the raw value (as above) keeps one canonical object per node and avoids proxies of proxies. Whatever you choose, document it, because the alternative is raw and wrapped references to the same node circulating together and comparing unequal. **A skip list.** Objects a Proxy cannot transparently wrap must be handed through raw: class instances with `#private` fields, `Map`/`Set`/`WeakMap`/`Date`/typed arrays/`ArrayBuffer`, promises, and DOM nodes — all of them throw on method calls made with the proxy as receiver. Frozen objects are a separate hazard: proxy invariants forbid reporting a value different from a non-configurable, non-writable own property, so a wrapper that transforms values will throw on frozen data. If you want reactive `Map` and `Set`, you cannot rely on forwarding at all; you must return instrumented replacements for their methods, bound to the raw collection, and track on the collection rather than on a key. **Array handling.** Index writes and `length` writes both surface as `set` traps, so a single `push` fires more than one notification unless you batch. Deciding whether observers see one change or several is a design decision, not an implementation detail. ## The costs you are signing up for Every nesting level in an access path is another trap call, so hot paths pay proportionally. Debugging gets harder: a stack trace shows the mutation site, not the observer that reacted, and a devtools inspection of a proxied object shows the proxy's traps rather than plain data. And you have introduced a rule the whole team must internalise — where the boundary between reactive and raw sits, and which one crosses a serialization or worker boundary. ## How I would decide Default to shallow. Go deep only when three things hold: callers genuinely mutate in place at depth, the data is coordination-shaped rather than bulk numeric, and you are prepared to own the cache, the unwrap hatch and the skip list as first-class API. When only part of the state needs depth, split the store — a deeply reactive object for the small mutable region, raw references for bulk data — rather than making the whole graph pay. And whichever you choose, make the boundary visible in the type or naming so that the next reader does not guess.

  • What concretely breaks if you skip the target-to-proxy cache?
    Object identity becomes unstable: each read of `state.user` mints a fresh proxy, so `state.user === state.user` is `false`. Memoisation keyed on the value never hits, dependency comparisons always report a change, list diffing loses track of rows, and a `Set` accumulates one entry per read. It also allocates a proxy per access.
  • Why can't a deep wrapper make Map and Set reactive by plain forwarding?
    Because their data lives in internal slots reachable only from the real instance. Forwarding gives you the method, but calling it with the proxy as receiver throws. You must return instrumented functions bound to the raw collection that perform the operation and then notify, and track dependencies on the collection as a whole or per key, not through property traps.
  • How do you keep a bulk numeric dataset out of the reactive graph without giving up reactivity elsewhere?
    Split the boundary. Hold the buffer or typed array as a raw value referenced by a shallow marker object, and let reactivity track a cheap token — a version counter or the identity of the buffer — that you bump when the data changes. Consumers react to the token; the heavy data is never proxied, so iteration stays at plain-array speed.

saying these in an interview costs you the question

  • Wraps the whole graph eagerly at creation
  • Returns a fresh proxy on every nested read
  • Assumes any object can be safely proxied
  • Lets raw and proxied references circulate together
  • Treats deep reactivity as free ergonomics

context