Skip to main content

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 MessageChannel instead of setTimeout
  • Object.is vs ===, 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:

  1. createCounter() runs. A fresh count variable is born at 0.
  2. It returns increment, which was created next to count, so it keeps a live reference to that variable in its backpack.
  3. createCounter() finishes, but count does not die. JavaScript keeps it alive as long as anything still references it.
  4. Calling createCounter() again creates a brand-new, independent count. counterA and counterB share 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:

  1. You click Increment once. React re-renders: it calls Counter() again from the very top. That call creates a new count (value 1) and a new logLater closing over it. The old render's backpack is untouched.
  2. You click "Log count in 3s", then quickly click Increment twice more. The screen shows 3.
  3. Three seconds later the console prints 1, not 3.

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 .then callbacks, 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:

  1. The whole script is one macrotask. 1: start prints.
  2. setTimeout(..., 0) does not run now, it schedules a new macrotask for a future turn, even with delay 0.
  3. Promise.resolve().then(...) schedules a microtask.
  4. 4: end prints. The current macrotask finishes and the call stack empties.
  5. Microtasks always run next, 3: promise prints.
  6. Only now does the loop pick the waiting macrotask, 2: setTimeout prints.

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:

  1. postMessage schedules a macrotask immediately, no countdown, no clamp.
  2. 1: sync code always first prints, because the current code always finishes before any task runs.
  3. 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 NaNfalsetrue
+0 vs -0truefalse
'a' vs 'a'truetrue
{} vs {}falsefalse
null vs undefinedfalsefalse

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 setState with a value that is Object.is-equal to the current state, React skips the re-render. So setState(NaN) on state that is already NaN correctly does nothing, with === it would re-render forever.
  • Dependency arrays: useEffect(..., [dep]) re-runs only when a dep is not Object.is-equal to last render's. A NaN dep 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:

  1. { ...state } creates a shallow copy of the top level.
  2. Inside it, { ...state.user } copies the user level.
  3. Inside that, { ...state.user.address, city: 'Paris' } copies address and overwrites one key.
  4. 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

Rendered diagram (PNG hi-res):

rendered diagram

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.is is just a stricter ===. Reality: it's differently strict, kinder to NaN, 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. MessageChannel sits exactly in between.
  • Object.is makes "changed?" answerable for every value. Including NaN, 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

  1. 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.
  2. Build the counter factory and add a second returned function, reset, that also closes over count. Interleave calls to counterA and counterB and confirm the two backpacks stay independent.
  3. In a React app, reproduce the stale-closure demo above. Then change the increment to the functional form setCount(c => c + 1) and log c inside the updater, it receives the latest state, no matter which render scheduled it. Part 3 explains why.
  4. Check every row of the Object.is table 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.
  • MessageChannel delivers 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: NaN equals NaN, but +0 does 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.

Next

The Problem React Solves →