Bindings, not boxes
The foundational JavaScript mental model of how variables bind to values, where the course's first unit on JavaScript and TypeScript begins.
A teammate opens a pull request that updates a user record before sending it to an analytics helper. The update ships, and within an hour two pieces of UI on the dashboard start drifting out of sync. The sidebar shows the old name, the header shows the new one, and only a refresh brings them back together. The cause turns out to be ordinary: the helper changed the same object the calling component still held a reference to. This bug, along with a long list of “why did my state silently change?” stories like it, comes from treating = as if it duplicates a value. What = actually does is bind a name to a value. By the end of this lesson you’ll know exactly which values are shared and which aren’t. The fix isn’t a habit of copying everything defensively; it’s a mental model precise enough that you can predict the behavior before you run the code.
Names and values
Section titled “Names and values”The rest of the lesson is built on one rule: in JavaScript, = does not duplicate the value on its right. It binds the name on its left to that value. Whether anything gets copied depends entirely on what kind of value it is.
JavaScript sorts its values into two categories, and the category a value belongs to decides how = behaves.
Primitives are values JavaScript holds directly. There are seven of them: string, number, boolean, bigint, symbol, null, and undefined. When you assign a primitive to one name and then to another, the value itself is copied, and the two names are independent from that moment on.
Objects are the other category, and arrays and functions count as objects too. Here the name doesn’t hold the object; it holds a reference to it, a small pointer-like value that records where the object lives. When you assign an object to another name, what gets copied is the reference, not the object, so both names now point at the same object.
The right panel is the one that catches people out. If user and alias both point at the same object, then a change made through one name shows up when you read through the other:
const user = { name: 'Ada', age: 36 };const alias = user;
alias.name = 'Grace';console.log(user.name); // 'Grace'Notice that const did not prevent this. const locks the binding, so you can’t reassign alias to point at something else, but it says nothing about whether the object the binding points at can change. That distinction gets its own lesson later in this chapter; for now, keep the diagram in mind.
What functions do with the values you pass them
Section titled “What functions do with the values you pass them”A function call is just another binding. Each parameter is a fresh name on the function side, and calling the function binds that name to whatever value you passed. The same primitive-versus-reference split applies, which settles the long-running “is JavaScript pass-by-value or pass-by-reference?” debate.
JavaScript is always pass-by-value. For an object, the value being passed is a reference.
That sounds like a play on words, but it’s the difference between two kinds of bug every engineer has shipped at least once. Compare the two ways a function can try to “change” a user.
function rename(user: { name: string; age: number }) { user = { name: 'Grace', age: 36 };}
const ada = { name: 'Ada', age: 36 };rename(ada);console.log(ada.name); // 'Ada'Inside rename, the parameter user is rebound to point at a brand-new object. That rebinding only affects the function’s local name. The caller’s ada binding is left alone: it still points at the original object, which was never modified.
function rename(user: { name: string; age: number }) { user.name = 'Grace';}
const ada = { name: 'Ada', age: 36 };rename(ada);console.log(ada.name); // 'Grace'Inside rename, the parameter user and the caller’s ada both point at the same object. The function doesn’t rebind anything; it reaches through its local reference and changes a property on the shared object. That change is visible everywhere the object is held.
Together the two tabs give you the rule. A function that reassigns its parameter is invisible to the caller, while a function that changes a property on the object the parameter points at is visible to the caller. You don’t need to memorize this: once you can picture the diagram in your head, you can work out the behavior at the call site step by step.
Shallow copy: the daily reach
Section titled “Shallow copy: the daily reach”You usually don’t want to change a value that came from somewhere else, whether it’s a function argument, a piece of state, or a row you fetched from the database. Any change you make will travel back through every reference still pointing at that value. What you do want is a modified version of it, and the standard move is to copy first, then modify the copy. The 2026 default for that copy is the spread operator.
const next = { ...prev };const nextItems = [...items];That’s the whole technique. For objects, { ...prev } builds a new object with the same own enumerable properties. For arrays, [...items] builds a new array with the same elements. Both take barely more typing than writing the value itself, and both are what an experienced engineer reaches for most of the time.
You’ll occasionally see Object.assign({}, prev) instead. It does the same thing and is worth recognizing in older code. The only reason to prefer it today is a rare interaction with Object.defineProperty semantics that this course never uses. Array.prototype.slice() plays the same legacy role for arrays, usually to copy arguments-like objects that can’t be spread. It too is rare, and worth recognizing when you meet it.
The word “shallow” is doing real work in “shallow copy.” The spread copies the top level, but for any nested object the copy gets the original’s reference. The nested object itself isn’t duplicated, so both the original and the copy point at the same one. Change it through either name and the other sees that change. The exercise below lets you prove this to yourself.
Predict what each binding holds after the spread copy and the two mutations. Replace each null with your prediction (a string), then run the tests.
Reveal the answer
const prediction = { originalName: 'Ada', copyName: 'Grace', originalStreet: 'Babbage Ave', copyStreet: 'Babbage Ave',};The spread copied the top level, so renaming copy.name left original.name as 'Ada'. But address was a reference, and the spread copied that reference rather than the object behind it. Both original.address and copy.address now point at the same nested object, so writing to copy.address.street writes to it for original too.
A shallow copy is almost always what you want. Most state shapes you’ll work with are flat or only one level deep, so a spread is enough to keep your change from leaking out. Reaching for a deep copy when you don’t need one is wasteful, both in keystrokes and in the work the runtime does. The one time to step up to a deep copy is when the value is genuinely nested and you can’t predict where the changes will land.
structuredClone: the deep copy default
Section titled “structuredClone: the deep copy default”Sometimes the data is genuinely nested: an object with objects inside it, a record holding a Map, a tree. There a spread doesn’t protect the inner references, so changes still leak through to the original. The 2026 answer is structuredClone, a global function built into Node 24 LTS and every browser the course targets. It needs no import, no library, and no hand-written tree walker.
const original = { name: 'Ada', address: { street: 'Lovelace Ln', city: 'London' },};
const copy = { ...original };copy.address.street = 'Babbage Ave';
console.log(original.address.street); // 'Babbage Ave'The spread copied the top level, but address is still shared. Writing through copy.address changes the original too.
const original = { name: 'Ada', address: { street: 'Lovelace Ln', city: 'London' },};
const copy = structuredClone(original);copy.address.street = 'Babbage Ave';
console.log(original.address.street); // 'Lovelace Ln'structuredClone walks the whole structure and copies every nested object on its own. No shared reference is left between original and copy, so changes stay where you make them.
structuredClone does more than walk objects recursively. It handles cyclical references , where an object contains a reference back to itself and a naive recursive copy would loop forever. It preserves the types that actually matter in production: Date, Map, Set, ArrayBuffer, typed arrays, and RegExp. It is also usually faster than the older workaround covered in the next section.
What it can’t clone is anything that carries behavior or identity beyond the data it represents.
The mental model you’re building here, “what can cross a serialization boundary?”, is the same one the course leans on later for Server Actions. Next.js serializes the arguments you pass into a 'use server' function with a superset of this same algorithm. That’s why structuredClone is worth learning as the canonical model, even though you won’t reach for it every day.
The JSON.parse(JSON.stringify(x)) you’ll see in legacy code
Section titled “The JSON.parse(JSON.stringify(x)) you’ll see in legacy code”Before structuredClone shipped natively, the workaround was the same one-liner everywhere:
const copy = JSON.parse(JSON.stringify(original));It does produce a deep copy. It also quietly transforms the data on the way through, because JSON.stringify only understands the JSON data model, and anything outside that model is lost or corrupted. That’s occasionally useful, when you want to force a value into wire format, and almost always a bug. The behavior is easier to remember once you’ve predicted it yourself.
Predict what this program prints, then press Check.
const original = { created: new Date('2026-01-15T10:00:00Z'), tags: new Set(['urgent', 'invoice']), ratio: undefined, note: 'Pay before EOM',};
const copy = JSON.parse(JSON.stringify(original));
console.log(copy);JSON.stringify walks the value and converts each piece to its JSON form: a Date becomes its ISO-string representation, a Set becomes a plain object with no enumerable own properties (and therefore an empty {}), and an undefined property is dropped entirely (JSON has no undefined). When JSON.parse reads the result back, every type that was converted comes back as its replacement — there is no way to recover Date-ness from a string, or Set-ness from {}. Reach for structuredClone unless you specifically want JSON’s lossy normalization.
Recognize the pattern when you see it in a codebase you’ve inherited, but don’t reach for it in new code.
The React-shaped reflex this builds toward
Section titled “The React-shaped reflex this builds toward”React detects state changes by reference equality. When you call a state setter, React compares the new value to the old one with ===. On two objects, === asks whether they are the same reference, not whether they have the same shape, and the next lesson covers that distinction in full. So if you change an array or object in place and hand the same reference back, React sees no change, skips the re-render, and your UI quietly drifts from your data.
The habit to build is copy, then modify, never change a value in place. The shallow-versus-deep choice you make here is the same one you’ll make every time you update React state. Most state shapes are flat enough that a spread does the job, and the rare deeply nested case is where structuredClone, or a targeted nested spread, earns its keep.
The React-specific patterns belong in the React chapters: functional setters, useReducer, and the cases where teams reach for a library called Immer, which lets you write what looks like mutation while it does copy-then-modify for you under the hood. If the habit is solid at the language level, those patterns will feel obvious by the time you meet them.
Check yourself
Section titled “Check yourself”Here is one exercise to confirm you can predict cross-boundary behavior without running the code. Instead of pattern-matching, apply the binding model one line at a time.
Predict what this program prints, then press Check.
const count = 1;const view = count;console.log(count + view);
const ada = { name: 'Ada' };function promote(user: { name: string }) { user.name = `Dr. ${user.name}`;}promote(ada);console.log(ada.name);
const original = { name: 'Ada', address: { city: 'London' } };const copy = { ...original };copy.name = 'Grace';copy.address.city = 'Paris';console.log(`${original.name}, ${original.address.city}`);Walk each line against the binding model:
-
viewwas bound to the value ofcount, not tocountitself. Primitives copy on assignment, soviewholds its own1. The sum prints2— there’s nothing pointing back at the original binding to disturb. -
promotereceives a reference to the same objectadapoints at. Mutating a property on that object is visible to the caller, soada.nameis now'Dr. Ada'. -
The spread copied the top level of
original, so renamingcopy.namedid not touchoriginal.name(still'Ada'). Butaddresswas a reference copied as a reference — both objects share the same nested address — so writing tocopy.address.cityalso changedoriginal.address.cityto'Paris'. The combined string is'Ada, Paris'.
If you predicted all three lines correctly, you have the binding model. From here on, every later concept that touches “what gets shared and what doesn’t” comes back to this same diagram: Server Action arguments, React state updates, structural typing.
External resources
Section titled “External resources”Step through any of the snippets in this lesson and watch the bindings, references, and heap update line by line.
MDN's canonical guide to the seven primitives, the object category, and how typeof and equality interact with them.
The full list of cloneable types, transferables, and the exact DataCloneError conditions you may hit in production.
Surma's web.dev article on why structuredClone replaced the JSON.parse(JSON.stringify(x)) workaround, with concrete edge cases.