A Map takes keys of any type, keeps insertion order, knows its size and is iterable; a plain object turns every key into a string or symbol and suits records with known fields. A Set holds unique values with a fast has; an array allows duplicates, and includes scans every item.
Object keys are strings: obj[user] stores the key "[object Object]", so two different objects collide; integer-like keys are also listed first, out of insertion order.
No inherited keys: a Map starts empty, while 'constructor' in {} is true; for keys that come from users, a Map (or Object.create(null)) avoids clashes with __proto__.
JSON: JSON.stringify(map) gives {}; convert with Object.fromEntries(map) and back with new Map(Object.entries(obj)).
**Set**: deduplicate with [...new Set(list)]; NaN counts as equal to itself; ES2025 adds union, intersection, difference and isSubsetOf.
In React state, replace instead of mutating: setIds((prev) => new Set(prev).add(id)).
ReactMiddle
Advanced JavaScript
Type coercion, call, apply and bind, the new operator, Map, Set and WeakMap, iterators and generators, Proxy, garbage collection, output puzzles, writing Promise.all, curry and an event emitter
Questions closed
0 / 18
- 0Review
- 4Asked often
Questions
WeakMap and WeakSet hold their keys weakly: an entry does not keep its key object alive and disappears once that object is collected. WeakRef holds one object weakly and returns it from deref() until it is gone. Use them to attach data to objects whose lifetime you do not control.
Keys must be objects (or non-registered symbols, since ES2023); new WeakMap().set('a', 1) throws "Invalid value used as weak map key".
**Not iterable, no size, no clear: the contents depend on when the garbage collector runs, so the API does not let you observe them.
Values are held strongly while the key is alive, but a value that points back to its own key does not keep the key alive.
Caching per object**: derived data for an immutable state object, as Reselect 5's default weakMapMemoize does; a Map here would keep every old state forever.
**WeakSet marks objects as seen or processed, for example for cycle detection in a deep clone.
WeakRef and FinalizationRegistry** suit a cache of large objects you can recreate. Collection timing is not guaranteed and finalizers may never run, so program logic must not depend on them.
They log [object Object], 3, 52, 2, 1, NaN, true and baNaNa. Binary + concatenates as soon as either operand is a string after both are converted to primitives; every other arithmetic operator converts both sides to numbers.
**[] + {}**: both objects become primitives first: [] turns into '' and {} into '[object Object]', so the result is a string.
**'5' - 2 vs '5' + 2**: - works only on numbers, so '5' becomes 5; + sees a string and concatenates.
**true + 1, null + 1, undefined + 1**: converting to a number gives 1, 0 and NaN.
**[] == ![]**: ! runs first and gives false, because every object is truthy. Then == turns the boolean into 0 and the array into '' and then 0, so it compares 0 == 0.
**'b' + 'a' + +'a' + 'a'**: the unary +'a' is NaN, which is then concatenated as text.
How objects become primitives: Symbol.toPrimitive if present, otherwise valueOf and then toString (toString first when a string is expected).
At the start of a statement, {} + [] gives 0 (try eval('{} + []')): {} is parsed as an empty block, which leaves +[]. Node's REPL wraps such input in parentheses and shows '[object Object]'.
All three set this for a regular function. call(thisArg, a, b) invokes it at once with the arguments listed one by one, apply(thisArg, [a, b]) invokes it with an array, and bind(thisArg, a) returns a new function with this and the leading arguments fixed for every later call.
The polyfill: keep the original function in a closure and return a wrapper that merges the preset and the new arguments and calls fn.apply(thisArg, args).
**A bound this is final**: call, apply or a second bind on a bound function cannot change it; only extra arguments get appended.
**new beats bind**: calling a bound function with new ignores the bound this and constructs an instance of the original; the wrapper checks new.target for that.
**Arrow functions ignore thisArg** in all three, because they take this from the enclosing scope.
**call by hand**: put the function on the object under a fresh Symbol key, call it as a method, then delete the key.
Today spread has replaced most uses of apply (Math.max(...nums)), and every bind creates a new function, so binding inside render hands a child a new prop each time.
It logs 1, 11, 2 and 12, then throws TypeError: Cannot read properties of undefined (reading 'count'). A regular method takes this from the way it is called, a bound function from bind, and the class-field arrow from the instance that created it.
**c.inc() gives 1**: a method call, so this is c.
**other.inc() gives 11**: the same function called on other; a method is not tied to the object or class it came from.
**other.incArrow() gives 2**: the arrow was created in c's constructor and captured this there, so it increments c.count whoever calls it.
**c.inc.bind(other).call(c) gives 12**: bind fixes this to other for good, and call cannot override it.
**inc() throws**: a plain call passes undefined as this, because class bodies are always strict.
Precedence: new, then bind, then call or apply, then the object before the dot, then a plain call; arrows ignore all of them.
new Fn(args) creates an empty object whose prototype is Fn.prototype, runs Fn with this set to that object and returns the object, unless Fn itself returns an object, which then replaces it. A primitive return value is ignored.
Reimplementation: Object.create(Fn.prototype), then Fn.apply(obj, args), then pick the result; that is the whole of myNew.
The prototype link is why instanceof works and why methods on Fn.prototype are shared by every instance.
Returning an object from a constructor is legal and sometimes used for caching or factories; returning 42 changes nothing.
Not everything is a constructor: arrow functions and object-literal methods throw "is not a constructor".
Classes throw "Class constructor X cannot be invoked without 'new'", so myNew cannot apply them; Reflect.construct(Fn, args) is the general form, and it also sets new.target.
**new.target** tells a function whether it was called with new, which lets a constructor refuse plain calls.
Every property has a descriptor: a value and a writable flag (or a getter and setter instead), plus enumerable and configurable. Object.seal forbids adding and deleting properties, Object.freeze also makes values read-only, and both are shallow, so nested objects stay mutable.
Defaults differ: properties created by assignment or in a literal have every flag true; Object.defineProperty defaults the missing ones to false, which is how you add a hidden, read-only field.
**enumerable: false** hides a property from Object.keys, for...in, spread and JSON.stringify, but not from direct access.
Loud or silent: writing to a frozen property throws a TypeError in strict code (modules, classes) and is silently ignored in sloppy scripts.
Shallow: after Object.freeze(config) the field config.retry.count is still writable; a deepFreeze freezes every nested object recursively.
**const is a different thing: it forbids reassigning the variable, not changing the object.
In practice** Immer, and so Redux Toolkit, freezes the state it produces, so an accidental mutation throws instead of silently skipping a re-render.
An object is iterable when it has a [Symbol.iterator]() method that returns an iterator: an object whose next() returns { value, done }. for...of walks the values of any iterable; for...in walks the enumerable string keys of an object, inherited ones included.
Who uses the protocol: for...of, spread, array destructuring, Array.from, new Map(iterable), Promise.all and yield*.
Built-in iterables: arrays, strings (by code point), Map, Set, typed arrays, arguments and NodeList. Plain objects are not; iterate over Object.entries(obj) instead.
Laziness: values are produced one next() at a time, so an iterator can be infinite or stop early.
Early exit: break or a thrown error calls the iterator's return(), which is where cleanup goes.
**for...in on arrays** yields string indexes plus every extra or inherited enumerable property, so use for...of or array methods.
ES2025 iterator helpers: map.values().filter(fn).toArray() chains map, filter, take and drop lazily without an intermediate array.
A generator function (function*) returns an iterator instead of running its body; each next() runs the body up to the next yield and pauses there with all local state intact. That makes lazy, possibly infinite sequences and step-by-step processes easy to write.
Two-way: next(value) resumes the generator and becomes the result of the paused yield; return() and throw() resume it with a return or an exception at that yield, so its finally blocks still run.
**yield* delegates to another iterable, which makes recursive tree walks short.
Async generators** (async function*) are read with for await...of: a paginated API becomes a stream of items, and a break stops before the next page is requested.
redux-saga is built on generators: a saga yields plain effect objects such as call(api.fetchUser, id), so a test steps through it without mocking the network.
**async/await behaves like a generator driven by promises, and Babel compiled it into exactly that for older browsers.
When not to**: for an ordinary list an array is clearer and faster.
It logs b BOOM 1, then d, then e again. A thrown error skips every fulfilment handler until a rejection handler takes it; the first catch returns a value, which puts the chain back on the success path, and the second error goes past c to the last catch.
**a is skipped**: a then without a rejection handler passes the rejection on unchanged.
**The first catch recovers**: whatever it returns becomes the fulfilled value of the next step, here 'BOOM 1'.
**c never runs**: the rejection handler of then(onFulfilled, onRejected) handles only a rejection of the previous promise, not an error thrown by its own onFulfilled. That is the practical difference from .then(f).catch(g).
**finally passes the outcome through: it receives no argument, runs either way and keeps the rejection going, unless its own callback throws or returns a rejected promise.
A chain with no final catch** ends in an unhandled rejection, reported in the console and through the unhandledrejection event.
Currying turns a function of several arguments into a chain of calls that collects them until there are enough. curry compares the collected arguments with fn.length: with enough of them it calls fn, otherwise it returns a function that waits for more.
Collect across calls: each partial call keeps the arguments gathered so far in a closure and appends the new ones, which is why sum(1, 2)(3) and sum(1)(2)(3) both work.
**fn.length** counts only the parameters before the first default value or rest parameter: (a, b = 2) => {} has length 1 and (...nums) => {} has 0. Curry such functions with an explicit arity.
Any number of arguments: the common interview variant add(1)(2)(3)() keeps collecting until an empty call ends the chain.
Currying vs partial application: partial application fixes some arguments once (fn.bind(null, a)); currying lets you supply them in any grouping.
Where it helps: building specialized functions such as setField('email') or filterBy('status') and composing pipelines; in everyday React code a plain closure is usually clearer.
Wrap the function, build a key from its arguments, return the cached result when the key is known, and otherwise compute it and store it. A plain cache grows without bound, so cap it: a Map keeps insertion order, which makes a small LRU cache easy, or key by object in a WeakMap.
Only for pure functions: the same arguments must give the same result with no side effects, or the cache returns stale answers.
The key: JSON.stringify(args) works for primitives, but it turns functions into null, throws on cycles and treats two different objects with equal contents as one; let callers pass their own key function.
**LRU on a Map**: on a hit, delete and re-insert the key to mark it as recent; when size passes the limit, delete cache.keys().next().value, the oldest entry.
One object argument: a WeakMap keyed by that object frees the entry together with the object.
Async functions: cache the promise so concurrent calls share one request, and delete the entry if it rejects, or one failure stays cached forever.
In React, useMemo is a cache of size one per component; memoize belongs in module-level helpers.
Keep a Map from event name to a list of listeners: on appends, off removes, emit calls every listener with the arguments, and once registers a wrapper that removes itself before calling the real listener. Have on return an unsubscribe function.
**once and off**: the stored function is the wrapper, so off(event, original) would miss it; keep a link (wrapper.original) and match either one.
**Changes during emit**: a once listener removes itself in the middle of the loop; iterate over a snapshot, or replace the array in off instead of splicing it, so no listener is skipped.
Errors: a listener that throws stops the rest of emit; wrap each call in try/catch if listeners must not affect each other.
Leaks: every on needs a matching off, for example in a useEffect cleanup; Node's emitter prints MaxListenersExceededWarning after 10 listeners on one event for this reason.
Duplicates: an array allows the same listener twice, as Node does; a Set silently keeps one.
In the browser you can also extend EventTarget and dispatch a CustomEvent.
Return a new promise, wrap each input in Promise.resolve, store each value at its own index and count the fulfilled ones: resolve when the counter reaches the total, reject as soon as any input rejects. An empty input resolves right away with [].
Order by index: values arrive in the order the promises settle, so write results[index] = value, never push.
**Count, do not check length**: assigning results[2] first makes results.length equal 3 while two slots are still empty.
**Promise.resolve(item)** accepts plain values and thenables, as the real Promise.all does.
A promise settles once: after the first reject, later resolve and reject calls are ignored, so no extra flag is needed.
Nothing is cancelled: the other inputs keep running after a rejection; pass an AbortSignal to stop them.
Variants on the same skeleton: allSettled stores { status, value } or { status, reason } and never rejects; race settles with the first input that settles.
Call fn in a loop inside try/catch; on an error worth retrying, wait baseDelay * 2 ** attempt milliseconds with random jitter and try again, and after the last attempt rethrow the error. Retry only transient failures: network errors, 429 and 5xx.
**return await** inside the try: a plain return fn() hands back the promise before it settles, and its rejection skips your catch.
Jitter: when a server restart fails a thousand clients at once, fixed delays bring them all back at the same moment; a random delay up to the backoff spreads them out.
Cap the delay (maxDelay) and the number of attempts, so the user does not wait for minutes.
Do not retry 4xx: a 400 or a 403 fails the same way again; for 429 honour the Retry-After header when the server sends one.
Idempotency: retrying a POST can create a second order; retry only safe requests, or send an idempotency key the server deduplicates on.
Cancellation: accept an AbortSignal, so leaving the page stops both the waiting and the request.
Start N workers that share one cursor into the task list: each worker takes the next index, awaits that task, stores the result at the same index and takes the next one until the list runs out. Promise.all over the workers resolves when every task has finished.
Why this shape
Tasks are functions, not promises: a promise is already running when you get it, so a pool of promises limits nothing.
Batches of N are slower: Promise.all per chunk waits for the slowest task of each chunk, while workers start a new task the moment one finishes.
**The shared next++ is safe**: JavaScript runs one piece of code at a time, and no await sits between reading the cursor and incrementing it.
Errors and cancellation
In this version the first rejection rejects the pool, but the other workers keep starting tasks; check a failed flag in the loop, or pass an AbortSignal to the tasks.
To report every outcome, store { status, value } or { status, reason } per index, as allSettled does.
In practice
Typical uses: uploading many files, prefetching hundreds of resources, calling a rate-limited API. Over HTTP/1.1 the browser already queues requests beyond about 6 connections per host, but HTTP/2 multiplexes far more over one connection, and the limit also protects the server and the device's memory.
When tasks arrive over time rather than as a list, a queue-based limit(fn) wrapper fits better; p-limit and p-map are the usual libraries.
A Proxy wraps an object and sends operations on it (reading, writing, deleting, in, listing keys) through trap functions you write, with Reflect forwarding the default behaviour. Libraries use it to see every read and write without the caller doing anything special.
How libraries use it
Immer: produce(state, (draft) => ...) hands you a proxy; writes are recorded and copied only along the changed path, so untouched branches keep their identity. The result is frozen, and drafts are revoked afterwards, so touching one later throws.
Vue 3: reactive() records in the get trap which effect reads which key and re-runs those effects from set. Vue 2 used Object.defineProperty, which could not see added keys and needed Vue.set.
MobX: observable() returns a proxy for plain objects and arrays to track what each reaction reads (a Map becomes an ObservableMap class instead); a class made observable with makeObservable is not proxied: its instance gets getters and setters.
What it costs
Speed: every access is a function call; fine for state, too slow for hot loops over thousands of objects.
Identity: proxy !== target, so ===, Set membership and WeakMap keys see two different objects.
Internal slots: a Map, Set or Date method called through a plain proxy throws a TypeError, because the proxy lacks the target's internal slots, and a class method that reads #private fields throws too; libraries wrap these by hand.
**structuredClone** and postMessage reject a proxy; unwrap it first (current() in Immer, toRaw() in Vue, toJS() in MobX).
Invariants: a trap may not lie about a non-configurable, read-only property; the engine throws a TypeError.
The engine frees objects that can no longer be reached from the roots (globals, the call stack, live handles), not objects that are merely unused. V8 splits the heap into a young generation, collected often and cheaply, and an old generation, collected less often by mark-compact.
How V8 collects
Young generation: most objects die young, so new objects go to a small space that the scavenger cleans by copying the few survivors out; its cost depends on what survives, not on the amount of garbage.
Old generation: objects that survive a couple of minor collections are promoted; a mark-compact collector marks from the roots incrementally and on helper threads, sweeps in the background, and during a short pause moves objects off fragmented pages.
Pauses still exist but usually last a few milliseconds; code that allocates heavily in a loop pays for it with more frequent collections.
Cycles are fine: two objects that only reference each other are unreachable and get collected.
The closure trap
All closures created in one scope share one context object, and V8 puts in it every variable that any of those closures uses.
In the example total reads report, so report lands in the shared context; the click handler never mentions it, yet keeps the context, and with it 8 MB, alive as long as the handler lives. Twenty calls retain about 160 MB.
Fixes: compute in a separate function, so the big value never enters the long-lived scope, or set the variable to null once you are done with it.
Finding it: in a DevTools heap snapshot the Retainers panel shows the chain from the handler through its context to report.