Core JavaScript (Q1–Q25)
var: function-scoped, hoisted and initialized to undefined, can be re-declared. let: block-scoped, hoisted but NOT initialized (Temporal Dead Zone until declaration), no re-declaration in same scope. const: same as let but binding cannot be reassigned (the object itself can be mutated).
var x = 1; var x = 2; // OK let y = 1; let y = 2; // SyntaxError const obj = { a: 1 }; obj.a = 2; // OK — mutating the object const obj = {}; // TypeError — re-assigning binding
Rule of thumb: Use const by default. Use let when you need to reassign. Never use var in modern JS.
A closure is a function that retains access to its outer lexical scope even after the outer function has returned. This happens because inner functions hold a reference to the outer scope's variable environment.
function makeCounter() { let count = 0; return { increment: () => ++count, decrement: () => --count, getCount: () => count }; } const counter = makeCounter(); counter.increment(); // 1 counter.increment(); // 2 counter.getCount(); // 2 — closure preserved count
Classic interview trick: the classic var loop closure bug — all callbacks share the same var i. Fix: use let in the loop or an IIFE to capture a fresh binding per iteration.
JS is single-threaded. The event loop coordinates:
- Call Stack — executes synchronous code frame by frame.
- Microtask Queue — Promises (
.then/.catch/.finally),queueMicrotask,MutationObserver. - Task Queue (Macrotask) —
setTimeout,setInterval, I/O, UI events.
Rule: After every macrotask, drain the ENTIRE microtask queue before running the next macrotask.
console.log('1'); setTimeout(() => console.log('2'), 0); Promise.resolve().then(() => console.log('3')); console.log('4'); // Output: 1, 4, 3, 2
Every JS object has an internal [[Prototype]] link to another object. When you access a property that doesn't exist on the object, JS walks up the prototype chain until it finds it or hits null.
const animal = { eat() { return 'eating'; } }; const dog = Object.create(animal); // dog's prototype = animal dog.bark = () => 'woof'; dog.eat(); // 'eating' — found up the chain dog.bark(); // 'woof' — own property
class syntax in ES6 is syntactic sugar over prototypal inheritance. Internally class Dog extends Animal sets Dog.prototype.__proto__ = Animal.prototype.
All three set the this context for a function. Difference: how arguments are passed and when it's called.
function greet(greeting, name) { return `${greeting}, ${name}! I am ${this.title}`; } const ctx = { title: 'Engineer' }; greet.call(ctx, 'Hello', 'Alice'); // called immediately, args spread greet.apply(ctx, ['Hi', 'Bob']); // called immediately, args in array const bound = greet.bind(ctx, 'Hey'); // returns new function, not called bound('Charlie'); // called later
A Promise is an object representing eventual completion/failure of async work. States: pending → fulfilled | rejected. Key combinators:
Promise.all(arr)— waits for ALL to settle. Rejects immediately if any rejects.Promise.allSettled(arr)— waits for ALL, never rejects early. Returns array of {status, value/reason}.Promise.race(arr)— settles with the first promise to settle.Promise.any(arr)— fulfills with first fulfillment; rejects if ALL reject (AggregateError).
// async/await is syntactic sugar over Promises async function fetchUser(id) { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error('Not found'); return await res.json(); } catch(err) { console.error(err); } }
Debounce: Execute the function only after N ms of silence (no new calls). Great for search-as-you-type — wait until user stops typing before firing API call.
Throttle: Execute at most once per N ms regardless of how many times it's called. Great for scroll/resize events — fire at regular intervals, not on every pixel.
// Debounce — waits for 300ms silence function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }
Shallow copy: Copies only the top-level properties. Nested objects are still references to the original.
const a = { x: 1, nested: { y: 2 } }; const b = { ...a }; // or Object.assign({}, a) b.nested.y = 99; // MUTATES a.nested.y too!
Deep copy: Recursively copies all levels. Options: structuredClone(obj) (modern, native), JSON.parse(JSON.stringify(obj)) (loses functions/Date/undefined), Lodash _.cloneDeep.
Instead of attaching event listeners to each child element, attach ONE listener to a parent and use event.target to identify which child was clicked. Works because events bubble up the DOM tree.
// Without delegation: N listeners for N items // With delegation: 1 listener on parent document.querySelector('#list').addEventListener('click', e => { if (e.target.matches('li')) { handleItemClick(e.target); } });
Benefits: fewer event listeners (memory), automatically handles dynamically added elements.
Generators are functions that can pause execution and resume later. They use function* syntax and yield to pause.
function* fibonacci() { let [a, b] = [0, 1]; while (true) { yield a; [a, b] = [b, a + b]; } } const gen = fibonacci(); gen.next().value; // 0 gen.next().value; // 1 gen.next().value; // 1
Generators are the foundation of many async iteration patterns and are used internally by Redux-Saga for side effects.
undefined: variable declared but not assigned a value. null: explicitly set to "no value" by developer. NaN (Not a Number): result of invalid arithmetic like 0/0 or parseInt('abc'). typeof NaN === 'number' (historical quirk). Check NaN with Number.isNaN(val) — never val === NaN (always false).
Both use weak references — they don't prevent their keys from being garbage-collected. Keys must be objects. Not iterable.
Use case for WeakMap: Associate private data or caches with DOM nodes or objects without preventing GC. If the object is removed, the WeakMap entry disappears too. WeakSet: Track "visited" objects without creating memory leaks.
The period between entering a block scope and the actual let/const declaration being reached. Accessing the variable in TDZ throws ReferenceError, unlike var which is initialized to undefined at hoist time.
console.log(x); // undefined (var is hoisted + initialized) var x = 5; console.log(y); // ReferenceError! TDZ let y = 5;
this in regular functions is determined at call time, not definition time. Arrow functions inherit this from their lexical enclosing scope (definition time) — they don't have their own this.
class Timer { constructor() { this.seconds = 0; } start() { // Arrow function: `this` = Timer instance ✓ setInterval(() => this.seconds++, 1000); // Regular function: `this` = undefined (strict) or global ✗ setInterval(function() { this.seconds++; }, 1000); } }
Memoization caches the return value of a function based on its inputs. On subsequent calls with the same arguments, return the cached result instead of recomputing.
function memoize(fn) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; }; } const fib = memoize(n => n <= 1 ? n : fib(n-1) + fib(n-2));
React Hooks (Q16–Q35)
The Virtual DOM is a lightweight JavaScript object tree representation of the actual DOM. When state changes: (1) React creates a new virtual DOM tree, (2) diffing algorithm (Fiber) compares it with the previous tree, (3) React calculates the minimal set of changes (patch), (4) applies only those changes to the real DOM (reconciliation). This batches DOM mutations and avoids expensive direct DOM access.
useState: simple state, simple updates. Good for independent primitive values. useReducer: when state transitions have complex logic, multiple sub-values, or when next state depends on previous in complex ways. useReducer is like redux in a component — state + dispatch(action) → new state.
// useState — simple counter const [count, setCount] = useState(0); // useReducer — multiple related actions const [state, dispatch] = useReducer((state, action) => { switch(action.type) { case 'INCREMENT': return { ...state, count: state.count + 1 }; case 'RESET': return { count: 0, error: null }; default: return state; } }, { count: 0, error: null });
useEffect runs side effects after render. The dependency array controls when it re-runs:
useEffect(fn)— runs after EVERY renderuseEffect(fn, [])— runs once after mount only (like componentDidMount)useEffect(fn, [dep1, dep2])— runs after mount and when dep1 or dep2 changes
Return a cleanup function to cancel subscriptions, clear timers, or abort fetch. It runs before the next effect and on unmount.
useEffect(() => { const controller = new AbortController(); fetch(`/api/data`, { signal: controller.signal }) .then(r => r.json()) .then(setData); return () => controller.abort(); // cleanup }, [userId]);
useMemo memoizes a computed value. useCallback memoizes a function reference.
// useMemo — expensive calculation const sortedList = useMemo( () => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items] ); // useCallback — stable fn ref for memo'd child const handleClick = useCallback( (id) => deleteItem(id), [deleteItem] );
When to use: Don't reach for these by default — they add complexity. Memoize when: (1) a calculation is provably expensive, or (2) a function passed to a React.memo() child is causing unnecessary re-renders.
useRef returns a mutable object { current: value } that persists across renders without triggering re-renders when changed. Two main uses:
- DOM references:
<input ref={inputRef} />→inputRef.current.focus() - Mutable instance variable: Store previous values, timer IDs, or any mutable data that shouldn't trigger re-render.
const prevCount = useRef(count); useEffect(() => { prevCount.current = count; }); // Now prevCount.current has the previous count value
Context provides a way to pass data through the component tree without passing props at every level (prop drilling). Create with createContext, provide with Provider, consume with useContext.
Use Context when: Theme, locale, auth state, small apps where Redux overhead isn't justified.
Use Redux when: Frequent state updates affecting many components (Context re-renders ALL consumers), complex state logic, need for time-travel debugging or middleware.
React.memo() is a Higher Order Component that memoizes a component itself — it skips re-rendering if props haven't changed (shallow equality by default). useMemo memoizes a value inside a component. useCallback memoizes function references so that children wrapped in React.memo don't get unnecessary re-renders from new function references.
const ExpensiveChild = React.memo(({ data, onClick }) => { // Only re-renders when data or onClick reference changes return <div onClick={onClick}>{data}</div>; });
Custom hooks are functions starting with use that encapsulate reusable stateful logic. Create one when you find yourself duplicating useState/useEffect/useCallback logic across multiple components.
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch(url).then(r => r.json()).then(setData) .catch(setError).finally(() => setLoading(false)); }, [url]); return { data, loading, error }; }
Keys help React identify which list items have changed/added/removed during reconciliation. Keys must be unique among siblings and stable across renders. Using array indices as keys can cause bugs when the list is reordered (React thinks elements moved but didn't change), causing stale state in form inputs.
// Bad: index as key — breaks with sorting/filtering {todos.map((t, i) => <Todo key={i} item={t} />)} // Good: stable unique ID {todos.map(t => <Todo key={t.id} item={t} />)}
Code splitting breaks the JS bundle into smaller chunks that load on demand, reducing initial page load time. React's React.lazy() + Suspense enables this natively for component-level splitting.
const Dashboard = React.lazy(() => import('./Dashboard')); function App() { return ( <Suspense fallback={<div>Loading...</div>}> <Dashboard /> </Suspense> ); }
Route-level code splitting is the most impactful — each page loads only the JS it needs.
Advanced Topics (Q26–Q40)
Tree shaking is dead-code elimination during bundling. Bundlers (Webpack, Rollup, Vite) analyze ES module import/export statements (static analysis) and remove unused exports from the final bundle. Only works with ES modules (not CommonJS require()). Named exports are more tree-shakeable than default exports of large objects.
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that blocks requests from origin A to origin B unless server B explicitly permits it. The server must include Access-Control-Allow-Origin header. For requests with custom headers or methods other than GET/POST/HEAD, a preflight OPTIONS request is sent first. Fix: configure CORS on the backend, or use a proxy for development. Never use * with credentials.
| Storage | Capacity | Expiry | JS Access | Sent with Requests |
|---|---|---|---|---|
| localStorage | ~5 MB | Until manually cleared | Yes | No |
| sessionStorage | ~5 MB | Tab/window close | Yes | No |
| Cookies | ~4 KB | Set by expires/max-age | Yes (unless HttpOnly) | Yes (same origin) |
Auth tokens: use HttpOnly Secure SameSite cookies. Don't store JWTs in localStorage — accessible via XSS.
Web Workers run JavaScript in a background thread, separate from the main UI thread. They have no DOM access. Communication is via postMessage. Use them for CPU-intensive tasks (image processing, large data parsing, encryption) that would otherwise block the UI thread and cause jank. Service Workers are a specialized type of Web Worker used for caching (PWAs) and background sync.
Sync iterables (Symbol.iterator) work with for...of. Async iterables (Symbol.asyncIterator) work with for await...of — each iteration step can be a Promise.
async function* paginate(url) { let nextUrl = url; while (nextUrl) { const res = await fetch(nextUrl).then(r => r.json()); yield res.data; nextUrl = res.nextPage; } } for await (const page of paginate('/api/items')) { processPage(page); }
State Management (Q31–Q40)
- Single source of truth: All app state in one store.
- State is read-only: Only dispatch actions to change state — never mutate directly.
- Changes via pure reducers: Reducer = (state, action) → newState. No side effects.
Redux Toolkit (RTK) is now the standard — createSlice, createAsyncThunk, Immer under the hood for "mutable-looking" reducers. RTK Query handles data fetching and caching.
React Query handles server state — data that lives on a server and is fetched async. Redux is for client state. React Query provides: automatic caching with stale-time, background refetching, pagination/infinite scroll, optimistic updates, and request deduplication — all out of the box. For most CRUD apps, React Query + useState/Zustand replaces the need for Redux entirely.
Prop drilling is passing props through multiple intermediate components that don't need them, just to get data to a deeply nested child. Solutions in order of complexity: (1) Component composition — restructure to avoid deep nesting, (2) React Context — for low-frequency updates (theme, auth), (3) Zustand/Jotai — lightweight state managers, (4) Redux — for complex shared state across many components.
Fiber (React 16+) made reconciliation interruptible. Before Fiber, reconciliation was synchronous — it would block the main thread for large trees. Fiber breaks reconciliation into units of work that can be paused, resumed, or abandoned. React 18 builds on Fiber with Concurrent Mode: startTransition marks low-priority updates (search filtering) so urgent updates (typing) interrupt them. useTransition exposes this API. Suspense for data fetching also relies on concurrent rendering.
Diagnosis first: Use React DevTools Profiler to identify slow components. Then:
- Wrap expensive components in
React.memo() - Stabilize function props with
useCallback - Memoize expensive computed values with
useMemo - Virtualize long lists with
react-windoworreact-virtual - Code-split large routes with
React.lazy - Move expensive state down — colocate state closer to where it's used
- Use
startTransitionfor non-urgent updates (filtering, searching)
More JS (Q41–Q50)
class MyPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; this.handlers = []; const resolve = (val) => { if (this.state !== 'pending') return; this.state = 'fulfilled'; this.value = val; this.handlers.forEach(h => h.onFulfilled(val)); }; executor(resolve); } then(onFulfilled) { return new MyPromise((resolve) => { if (this.state === 'fulfilled') { resolve(onFulfilled(this.value)); } else { this.handlers.push({ onFulfilled: v => resolve(onFulfilled(v)) }); } }); } }
map: transforms each element, returns new array of same length. filter: returns new array of elements that pass predicate. reduce: accumulates to a single value (can build any output). forEach: side effects only, returns undefined. Key: map/filter/reduce are pure and chainable; forEach is for side effects.
Symbol: unique, immutable identifier. Every Symbol() call creates a distinct value. Used as unique object keys to avoid name collisions. Well-known symbols like Symbol.iterator customize JS built-in behavior.
BigInt: arbitrary-precision integers. Use n suffix: 9007199254740993n. Needed when working with numbers exceeding Number.MAX_SAFE_INTEGER (2^53 - 1), e.g., database IDs, cryptography.
CommonJS: synchronous, dynamic (can require() inside conditionals), resolves at runtime. module.exports. Used in Node.js historically. ES Modules: asynchronous, static (imports hoisted and resolved at parse time), enables tree shaking. export/import. Modern standard. Mix-and-match requires interop care.
A Service Worker is a script running in the background, independent of the webpage. It intercepts network requests (acts as a programmable proxy) enabling: offline caching (Cache API), background sync, push notifications. PWAs (Progressive Web Apps) use Service Workers to work offline and be installable. Lifecycle: install → activate → fetch events.