var is function-scoped, hoisted with the value undefined and can be redeclared. let and const are block-scoped and sit in a temporal dead zone until their declaration runs, so reading them earlier throws a ReferenceError; const also cannot be reassigned.
Block scope: a let inside if or for stays in that block; a var leaks to the whole function.
Hoisting: all three declarations are hoisted, but only var has a usable value (undefined) before its line.
**const is not immutability**: const user = {} still allows user.name = 'Ada'; it only forbids user = ....
Loops: let creates a new binding per iteration, which is why closures in a for (let ...) loop each see their own value.
In practice: const by default, let when you reassign, var never in new code.
ReactJunior
JavaScript for React
Closures, scope, this, prototypes, equality, the event loop, promises and async/await, modules, immutability, array methods
Questions closed
0 / 21
- 0Review
- 15Asked often
Questions
=== compares without type conversion: the values must have the same type and value. == first converts the operands to a common type, so '1' == 1 and 0 == '' are true. Use ===; the one common exception is x == null, which matches both null and undefined.
Objects compare by reference with both: {} === {} is false, and two arrays with equal contents are not equal.
**NaN**: NaN === NaN is false; check with Number.isNaN(x).
**Object.is** works like === except that Object.is(NaN, NaN) is true and Object.is(0, -0) is false; React uses it to compare state and dependencies.
**Surprises of ==**: [] == false and '0' == false are true, while null == 0 is false.
Primitives (string, number, bigint, boolean, undefined, null, symbol) are immutable values copied on assignment. Objects, arrays and functions are reference types: a variable holds a reference, so copying the variable shares the same object.
Copy vs share: let b = a with a number copies the value; with an object both names point to one object, and a change through one is visible through the other.
Function arguments are always passed by value, but for an object that value is the reference, so the function can mutate the caller's object.
Comparison: primitives by value, objects by identity.
Why React cares: React compares state and props with Object.is; a mutated object keeps the same reference, so React sees no change and may skip the re-render.
**typeof null is 'object'**: a historical bug kept for compatibility.
undefined means no value was ever assigned: an uninitialized variable, a missing property or argument, a function without return. null is a value you assign on purpose to say "nothing here". null == undefined is true, while null === undefined is false.
Who produces which: the language gives undefined (obj.missing, [].find(...) with no match); APIs and your code return null deliberately (document.getElementById with no match, useState<User | null>(null)).
Defaults: default parameters and destructuring defaults replace only undefined, so greet(null) keeps null; ?? and ?. treat both as missing.
JSON: JSON.stringify({ a: undefined, b: null }) gives {"b":null}, so send null to clear a field through an API.
Checking: value == null matches both; typeof null is 'object', so typeof cannot detect null.
A closure is a function together with the variables of the scope where it was created. The function keeps access to those variables even after the outer function has returned.
Private state: a counter factory keeps count hidden, reachable only through the functions it returns.
Callbacks: every event handler and setTimeout callback is a closure over the variables around it.
In React: each render's handlers and effects close over that render's props and state, so a handler sees the values from the render that created it.
Stale closure: an interval set up once in an effect keeps reading the first render's count; fix it with the updater form setCount(c => c + 1) or correct dependencies.
Memory: a closure keeps its captured variables alive, so a long-lived listener can hold on to large objects.
It logs 3 three times. var creates one i for the whole function, all three callbacks close over that same variable, and by the time the timers fire the loop has finished and left i at 3.
Timers run later: setTimeout(..., 0) queues a task that runs only after the current script finishes.
**Fix with let**: for (let i = 0; ...) creates a fresh binding per iteration, so each callback sees its own value and the output is 0, 1, 2.
Pre-ES2015 fixes: wrap the body in an IIFE, or pass the value as an argument: setTimeout(console.log, 0, i).
The same root cause in React: a callback reading a variable that changed after the callback was created.
It logs hi, undefined and hello, then throws TypeError: wave is not a function. A function declaration is hoisted with its body; var hello = function... hoists only the variable, which holds undefined until its line runs, and an arrow stored in a var behaves the same way.
What the engine does first: before running a scope it creates every binding in it. Function declarations get their function at once, var bindings start as undefined, and let, const and class stay uninitialized (the temporal dead zone).
**typeof hello is 'undefined'**: the variable exists but holds no function yet, so calling hello() there would throw the same TypeError as wave().
**With let or const** the early call throws ReferenceError: Cannot access 'wave' before initialization; so does new Foo() above class Foo.
Hoisting is per function: a var label anywhere in a function shadows the outer label from its first line, so a console.log(label) above it prints undefined.
The dead zone is about time, not position: a function may use a const declared below it if it is called after that line has run, which is why components can call helpers defined further down the module.
Destructuring unpacks object and array values into variables; spread ... expands an iterable or object into another; rest ... collects what is left; ?. returns undefined instead of throwing when the left side is null or undefined; ?? gives a fallback only for null and undefined.
**?? vs ||**: count || 10 also replaces 0 and ''; count ?? 10 keeps them, so use ?? for numeric and string defaults.
Destructuring defaults apply only to undefined: const { size = 'md' } = props.
Rest in props: function Button({ variant, ...rest }) takes what it needs and forwards the rest to <button {...rest}>.
**?. with calls and indexes**: onClose?.(), list?.[0].
Spread is shallow: nested objects are still shared.
map, filter, find, some, every, reduce and slice return new values and leave the array alone, which is what React state needs. push, pop, splice, sort, reverse and fill change the array in place; ES2023 added copying versions: toSorted, toReversed, toSpliced, with.
**map: renders lists and transforms items; the result always has the same length.
filter**: removes an item from state: items.filter(i => i.id !== id).
**find / findIndex**: the first match, or undefined / -1; findLast searches from the end.
**reduce: folds to one value such as a total or a lookup object; a plain loop is often clearer.
Sorting state**: [...items].sort(cmp) or items.toSorted(cmp); items.sort() mutates the state array, and React may not re-render.
**includes vs indexOf**: includes also finds NaN.
A shallow copy creates a new top-level object but shares the nested objects with the original; a deep copy duplicates every level. Spread ({...obj}, [...arr]) and Object.assign are shallow; structuredClone(obj) makes a deep copy.
The shallow trap: after const b = { ...a }, b.address.city = 'Paris' also changes a.address.city.
**structuredClone** (all modern browsers, Node 17+): handles nested objects, arrays, Map, Set, Date and cycles; throws on functions and DOM nodes and drops class prototypes.
**JSON.parse(JSON.stringify(x))**: the old trick; it loses undefined and functions, turns a Date into a string, and empties Map and Set.
React rarely needs a deep copy: copy only the path you change ({ ...user, address: { ...user.address, city } }), so unchanged branches keep their identity and memoized children skip work.
Immer (built into Redux Toolkit) lets you write mutating code and produces that path copy for you.
For a regular function this depends on the call: obj.method() gives obj, call, apply and bind set it explicitly, new gives the new object, and a plain call gives undefined in strict mode. An arrow function has no own this and takes it from the scope where it was defined.
**Losing this**: const f = obj.method; f(), or passing obj.method as a callback, drops the object, so this is undefined.
Fixes: obj.method.bind(obj), an arrow wrapper () => obj.method(), or a class field arrow handle = () => {...}.
Arrows are poor methods: an arrow in an object literal takes this from the outer scope, not from the object; arrows also cannot be called with new and have no arguments.
React history: class components needed this.handleClick = this.handleClick.bind(this); function components have no this at all.
Modules and classes run in strict mode, so the old rule "a plain call gets window" applies only to sloppy scripts.
Every object has a hidden link to another object, its prototype. When a property is missing on the object, JavaScript looks it up along this prototype chain until it reaches null. class is cleaner syntax over the same mechanism: its methods live on ClassName.prototype.
Lookup, not copy: all instances share one method on the prototype, and a method added there becomes visible to existing instances.
**Object.getPrototypeOf(obj)** reads the link (__proto__ is the legacy accessor); Object.create(proto) makes an object with the given prototype.
**extends** links Child.prototype to Parent.prototype; super calls the parent's constructor or method.
Own vs inherited: Object.hasOwn(obj, 'x') checks the object itself; 'x' in obj checks the whole chain.
**What class adds**: private fields #count, static members, and a constructor that throws when called without new.
A module has any number of named exports, imported by exact name in braces, and at most one default export, imported under any name. ES modules (import/export) are static and resolved before code runs; CommonJS (require) is Node's older format, loaded synchronously at runtime.
Named exports keep names consistent across the codebase and help editors auto-import and rename; many teams use them everywhere except where a tool requires a default (React.lazy, Next.js pages).
The static structure enables tree shaking: the bundler sees which exports are used and drops the rest.
**Dynamic import()** returns a promise and makes a separate chunk; React.lazy(() => import('./Chart')) is built on it.
Live bindings: an imported let reflects later reassignments in the exporting module; a value taken from require is a snapshot.
Modules are strict and scoped: top-level variables are not globals, and top-level await is allowed.
A Promise is an object for a value that arrives later: it starts pending and settles once, either fulfilled with a value or rejected with an error. async/await is syntax over promises: an async function always returns a promise, and await pauses that function until a promise settles.
Chaining: .then returns a new promise, so steps run in order; .catch handles a rejection from any earlier step; .finally runs either way.
**await does not block the thread: only the function is suspended, the rest of the program keeps running, and the continuation runs as a microtask.
Errors**: an awaited rejected promise throws, so wrap it in try/catch; without one, the async function's own promise rejects.
Sequential vs parallel: two awaits in a row run one after the other; start both and await Promise.all([...]) to run them together.
**fetch resolves on HTTP errors**: check response.ok; it rejects only on a network failure.
All four take several promises and return one. all resolves with every result or rejects on the first error; allSettled waits for all and reports each outcome; race settles like the first promise to settle; any resolves with the first success and rejects only if all fail.
**Promise.all: independent requests that are all required; one failure rejects the whole thing, though the other requests keep running.
Promise.allSettled**: a dashboard where one failed widget should not hide the rest; each item is { status: 'fulfilled', value } or { status: 'rejected', reason }.
**Promise.race**: a timeout around a request; the losing promise is not cancelled, so use AbortController to really stop a fetch.
**Promise.any**: the fastest of several mirrors; it rejects with an AggregateError when all fail.
None of them starts work: the work starts when you create the promises; these methods only wait.
Wrap the await in try/catch where you can do something about the failure, and let it propagate elsewhere: a rejection travels up through every function that awaits it, like a thrown exception. A rejection nobody handles fires unhandledrejection in the browser and stops a Node process.
Only awaited promises are caught: try { save(); } without await, or return save() inside the try, leaves the block before the promise rejects, so the catch never runs. Write await save() or return await save().
**Fire-and-forget calls need their own .catch: analytics, a prefetch or an async click handler has no caller to receive the error, and an error boundary does not see it either.
Started promises awaited one by one**: in const a = loadA(); const b = loadB(); await a; await b; a quick failure of b has no handler while a is pending, so it is reported as unhandled. Promise.all attaches handlers to all of them at once.
Catch where you can act: show a message, retry or fall back; elsewhere rethrow, wrapped with { cause: error } if you add context. An empty catch {} hides bugs.
The error can be anything: strict TypeScript types it as unknown, so check error instanceof Error; an aborted fetch rejects with an AbortError you usually ignore.
**The global unhandledrejection listener** is for reporting, as error monitoring tools do, not for handling. Since Node 15 an unhandled rejection ends the process with exit code 1.
JavaScript runs one piece of code at a time on a call stack. When the stack is empty, the event loop first runs all queued microtasks (promise callbacks, await continuations, queueMicrotask), then lets the browser render if needed, then takes the next macrotask (a timer, an event, a message).
Async APIs run outside the stack: the browser handles setTimeout, fetch and events and queues a callback when they are ready.
Microtasks first: the whole microtask queue drains after every task, so a .then always runs before a setTimeout(..., 0) queued at the same moment.
Starvation: microtasks that keep queueing more microtasks block rendering and input, just like a long loop.
Long tasks freeze the page: a 200 ms synchronous loop delays clicks and frames; split the work, move it to a Web Worker, or in React mark the update as a transition.
**requestAnimationFrame** callbacks run right before the next paint.
It logs 1, 4, 6, 3, 5, 2. Synchronous code runs first, including the start of the async function up to await; then the microtasks run in the order they were queued (the .then, then the await continuation); the setTimeout callback is a macrotask and runs last.
**1 and 6: plain synchronous logs.
4**: an async function runs synchronously until its first await.
**3 before 5**: the .then callback was queued before the await continuation.
**2 last**: even a 0 ms timer waits until the stack and the whole microtask queue are empty.
forEach ignores the promises its callback returns, so saveAll never waits for them: it logs done immediately, and a failed save becomes an unhandled rejection instead of an error the caller can catch.
**forEach is synchronous**: it calls each async callback, each returns a pending promise, and forEach throws them away.
Parallel fix: await Promise.all(users.map((user) => saveUser(user))) starts every save and waits for all of them.
Sequential fix: for (const user of users) await saveUser(user); when order matters or the server cannot take many requests at once.
Errors: with either fix a failed save rejects saveAll, so the caller's try/catch sees it.
Same trap: map with an async callback returns an array of promises that you still have to await.
debounce delays a call until a burst of events has been quiet for N ms, so only the last call runs, for example a search request after the user stops typing. throttle runs at most once per N ms during the burst, which suits scroll or resize handlers that must keep updating.
How debounce works: each call clears the pending timer and starts a new one; a closure holds the timer id.
**Keep the latest arguments and this: pass them through to the delayed call.
In React**: create the debounced function once (useMemo or a ref), not on every render, or each render gets its own timer and nothing is debounced; cancel it on unmount.
Often you need a value, not a call: a useDebouncedValue hook, or useDeferredValue to keep typing responsive without a fixed delay.
Libraries: lodash.debounce adds leading, trailing, maxWait and cancel.
A leak is memory that stays reachable after you stop needing it, so the garbage collector cannot free it. In an SPA the usual causes are listeners, timers and subscriptions that outlive their component, caches that only grow, and detached DOM nodes still referenced from a long-lived object.
How the GC decides: engines mark everything reachable from the roots (globals, the stack) and sweep the rest; a reference cycle on its own does not leak.
Forgotten cleanup: a window.addEventListener, setInterval, WebSocket or store subscription added in useEffect without a cleanup keeps the unmounted component's closure alive.
Unbounded caches: a module-level Map keyed by id grows forever; cap it, use an LRU, or a WeakMap keyed by objects.
Detached DOM: a ref or variable that holds a removed node keeps the node and its whole subtree.
Finding them: in the Chrome DevTools Memory panel take a heap snapshot, repeat the action (open and close a page) several times, take another, and compare; look for growing counts of your objects and "Detached" elements. The Performance monitor shows the JS heap and DOM node count over time.
Not every growth is a leak: TanStack Query and routers keep caches on purpose and evict them later (gcTime).