JavaScript You Actually Need
What you'll learn
- How closures really work, and why every render of a component creates brand-new ones
- The event loop's real order: call stack, then microtasks, then one macrotask
- Why React schedules work with
MessageChannelinstead ofsetTimeout Object.isvs===, the comparison React actually uses for state and dependencies- Immutable update patterns for nested objects and arrays, and why React demands them
Here's a secret about learning React internals: most "React mysteries" aren't React at all. Stale state, updates that batch in surprising ways, effects that fire at unexpected times, these are plain JavaScript behaviors showing through the library. This chapter collects the five JS ideas the rest of the tutorial leans on constantly. Each section ends with a pointer to where the idea comes back, so treat this as loading ammunition before the battles.
1. Closures: functions carry a backpack
Jargon: "closure". A function bundled with the variables that were in scope where it was created. Picture the function carrying a backpack of variables it can open anytime, even after the code that made it has finished running.
The clearest way to see a closure is a counter factory:
function createCounter() {
let count = 0; // a private variable, born inside this call
return function increment() {
count = count + 1; // increment "closes over" count
return count;
};
}
const counterA = createCounter();
const counterB = createCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 — B has its OWN count
What happens:
createCounter()runs. A freshcountvariable is born at0.- It returns
increment, which was created next tocount, so it keeps a live reference to that variable in its backpack. createCounter()finishes, butcountdoes not die. JavaScript keeps it alive as long as anything still references it.- Calling
createCounter()again creates a brand-new, independentcount.counterAandcounterBshare nothing, two separate backpacks.
Step 4 is the one to remember forever: every call of a function creates new variables, and therefore new closures.
Every render is a new closure
Now the payoff. A function component is just a function, and every render is a fresh call to it:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
function logLater() {
setTimeout(() => {
console.log('count was:', count); // which render's count?
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={logLater}>Log count in 3s</button>
</div>
);
}
What happens:
- You click Increment once. React re-renders: it calls
Counter()again from the very top. That call creates a newcount(value1) and a newlogLaterclosing over it. The old render's backpack is untouched. - You click "Log count in 3s", then quickly click Increment twice more. The screen shows
3. - Three seconds later the console prints
1, not3.
Why? The timer callback closed over the count from the render in which the button was clicked. Later renders created later backpacks; this callback never sees them. This is the famous stale closure bug, and it's not a React quirk, it's step 4 of the counter factory wearing a React costume. You'll meet it again in Part 3, where it's the reason effect and callback dependency arrays exist.
2. The event loop: who runs when
JavaScript executes one thing at a time on a single thread. The event loop is the scheduler that decides what runs next.
Jargon: "call stack". The pile of function calls currently executing. Only when it's empty can any new piece of work start. Jargon: "macrotask" (or "task"), a chunk of work delivered by the browser: a timer firing, a click, a message. The loop runs one macrotask per turn. Jargon: "microtask". A small follow-up job: promise
.thencallbacks,queueMicrotask. After every task, the loop drains the entire microtask queue before moving on.
Don't memorize that. Run the experiment:
console.log('1: start');
setTimeout(() => console.log('2: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: promise'));
console.log('4: end');
Predict the order first. The actual output is:
1: start
4: end
3: promise
2: setTimeout
What happens:
- The whole script is one macrotask.
1: startprints. setTimeout(..., 0)does not run now, it schedules a new macrotask for a future turn, even with delay0.Promise.resolve().then(...)schedules a microtask.4: endprints. The current macrotask finishes and the call stack empties.- Microtasks always run next,
3: promiseprints. - Only now does the loop pick the waiting macrotask,
2: setTimeoutprints.
The rule: finish the current code → drain ALL microtasks → maybe paint → run ONE macrotask → repeat. React leans on this ordering constantly: batching (the Render and Commit chapter) and scheduling (Part 4) are built directly on these guarantees.
3. MessageChannel: a doorbell with no timer
Macrotasks let you say "run this later, after the current work." But there's a trap: setTimeout(fn, 0) is never really zero. Browsers clamp nested timers, after a few levels of timers scheduling timers, the delay is forced up to about 4 milliseconds. Try to schedule fine-grained work through setTimeout and the clamp quietly eats your frame budget.
MessageChannel gives you a macrotask with no timer and no clamp:
const channel = new MessageChannel();
channel.port1.onmessage = () => {
console.log('2: ran as a macrotask, no timer involved');
};
channel.port2.postMessage('go');
console.log('1: sync code always first');
What happens:
postMessageschedules a macrotask immediately, no countdown, no clamp.1: sync code always firstprints, because the current code always finishes before any task runs.- On the very next turn of the loop, the message handler runs.
Why does a UI library care? React's scheduler constantly needs to say: "pause my rendering work, let the browser handle clicks and paint, then resume me as soon as possible." "As soon as possible" must be a macrotask (so the browser gets its turn) but must not be a clamped timer. MessageChannel is exactly that, and React uses it in browsers when it's available. This becomes the heartbeat of time-slicing in Part 4.
4. Object.is vs ===: the comparison React actually uses
You know ===. Object.is is almost the same, with two deliberate differences:
| Expression | === | Object.is |
|---|---|---|
NaN vs NaN | false | true |
+0 vs -0 | true | false |
'a' vs 'a' | true | true |
{} vs {} | false | false |
null vs undefined | false | false |
Verify it yourself:
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
What happens: the table above is the entire difference. Everything else about the two operators matches.
React compares with Object.is, not ===, in two places you'll live in daily:
- State bail-out: if you call
setStatewith a value that isObject.is-equal to the current state, React skips the re-render. SosetState(NaN)on state that is alreadyNaNcorrectly does nothing, with===it would re-render forever. - Dependency arrays:
useEffect(..., [dep])re-runs only when a dep is notObject.is-equal to last render's. ANaNdep is treated as stable, not as "changed every time."
You'll see both in Part 3. For now, file this away: when React asks "did this value change?", it asks with Object.is.
5. Immutable updates: copy what you touch
React state must be updated immutably, you never modify the existing object; you build a new one. The reason is pure pragmatism: React detects change by comparing references (with Object.is, from the previous section). Mutate an object in place and its reference stays identical, so React concludes "nothing changed" and your UI silently goes stale.
For nested data, the rule is: copy every level on the path to the thing you're changing.
const state = {
user: {
name: 'Ada',
address: { city: 'London', zip: 'E1' },
},
tags: ['admin', 'owner'],
};
// Changing the city: copy state, user, AND address
const next = {
...state,
user: {
...state.user,
address: { ...state.user.address, city: 'Paris' },
},
};
console.log(next.user.address.city); // 'Paris'
console.log(state.user.address.city); // 'London' — original untouched
console.log(next === state); // false — React can see the change
console.log(next.tags === state.tags); // true — untouched parts are SHARED
What happens:
{ ...state }creates a shallow copy of the top level.- Inside it,
{ ...state.user }copies theuserlevel. - Inside that,
{ ...state.user.address, city: 'Paris' }copiesaddressand overwrites one key. - Every level not on the path (
tags) keeps its old reference, sharing is fine and efficient, because nobody ever mutates it.
Arrays have their own immutable toolkit: spread to add, filter to remove, map to update. Never push, splice, or index-assignment on a value React is tracking:
const tags = ['admin', 'owner'];
const added = [...tags, 'editor']; // add
const removed = tags.filter(t => t !== 'admin'); // remove
const updated = tags.map(t => (t === 'owner' ? 'mod' : t)); // update
console.log(tags); // ['admin', 'owner'] — still intact
This pattern shows up everywhere: the Components and Purity chapter explains why mutation during render corrupts React's comparisons, and Part 3's state chapters use these exact patterns in every update.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: A closure captures a snapshot of values. Reality: it captures the variables themselves, live. If the variable later changes, the closure sees the change. The stale-state bug is the opposite case: an old closure legitimately holding an old variable from a past render.
- Misconception:
setTimeout(fn, 0)runs "right after this line." Reality: it schedules a macrotask that runs only after the current code and all pending microtasks, and it may be clamped to ~4 ms. - Misconception: Microtasks and macrotasks are two interchangeable queues. Reality: after every task, the entire microtask queue drains before the next macrotask runs. That strict priority is what makes promise-based scheduling predictable.
- Misconception:
Object.isis just a stricter===. Reality: it's differently strict, kinder toNaN, stricter about signed zero. React chose it so "did this change?" has a sane answer for every possible value. - Misconception: Immutable updates deep-copy everything. Reality: you copy only the levels you touch; untouched branches are shared by reference, which is both fast and memory-cheap.
Why it works this way
- Closures are why function components work at all. Handlers and effects are functions created during render; closures let them reach that render's props and state with no global registry.
- The microtask/macrotask split gives React reliable scheduling hooks. "After this handler finishes" (batching) and "as soon as the browser is free" (time-slicing) are both expressible in event-loop terms.
- A clamp-free macrotask is the only fair way to yield. Timers would throttle React's scheduler to 4 ms per step; microtasks would starve the browser of paints and input.
MessageChannelsits exactly in between. Object.ismakes "changed?" answerable for every value. IncludingNaN, so bail-outs and dependency checks never misfire on edge-case data.- Reference comparison makes change detection O(1). Immutability is the price you pay so React can diff huge trees by comparing pointers instead of walking every field.
Try it yourself
- Run the four-line event-loop experiment in a browser console. Then add
queueMicrotask(() => console.log('2.5: microtask'))after the promise and predict the new order before running. Expected output:1: start,4: end,3: promise,2.5: microtask,2: setTimeout. - Build the counter factory and add a second returned function,
reset, that also closes overcount. Interleave calls tocounterAandcounterBand confirm the two backpacks stay independent. - In a React app, reproduce the stale-closure demo above. Then change the increment to the functional form
setCount(c => c + 1)and logcinside the updater, it receives the latest state, no matter which render scheduled it. Part 3 explains why. - Check every row of the
Object.istable in a console, plus one bonus:Object.is(-0, 0). Predict each answer before pressing Enter.
Recap
- A closure is a function plus the live variables from where it was created. Every function call makes new variables, so every render makes new closures, the root of stale-state bugs.
- Event loop order: finish the current code, drain all microtasks, maybe paint, run one macrotask, repeat.
MessageChanneldelivers a macrotask with no timer clamp; React's scheduler uses it to yield to the browser without starving it.- React asks "did this change?" with
Object.is:NaNequalsNaN, but+0does not equal-0. - Update state immutably: copy each level on the path to your change, share the rest. React's O(1) reference checks depend on it.