JavaScript · A story for beginners · 12 min
A Visual Guide to References in JavaScript
Let me tell you about the afternoon JavaScript made me question my sanity. I was a few months into learning to code, and I did the most responsible thing I knew how to do: before changing an object, I saved a backup of it. Then I changed the original — and the backup changed too. I stared at those three lines for a very long time. I never touched the backup. Why did it change?
If you’ve ever muttered “I thought I made a copy of that!” at your screen, you’ve met the same ghost. And here’s the twist that took me embarrassingly long to see: JavaScript wasn’t broken, and neither was my code. My “backup” was never a second object at all.
So here’s my offer. Give me three short lines of code and about ten minutes. We’ll watch my bug happen in slow motion, with an X-ray view of what JavaScript is really doing — and by the end, you’ll never be fooled by a fake copy again.
One object. Two ways to reach it.
These are the three lines that got me. Before I explain anything, I want you to run them yourself, one at a time. Don’t study the code — just watch the panel below it, and count how many objects ever appear.
Names in your code
The actual object
score0Nothing has run yet.
The wires are our X-ray view. JavaScript draws them in memory but never shows them to you.
Did you count? Line 1 created exactly one object — that orange box on the right. But line 2, the line I trusted to make my backup, created nothing. No second box appeared. All it did was write a second name tag, backup, and run a wire from that tag to the object that already existed.
That’s the whole trick, and it’s worth saying slowly: a variable in JavaScript is not the thing itself. It’s a label with a wire leading to the thing. So when line 3 changed the score, there was only ever one score to change. Read it through player or read it through backup — you’re looking down two wires at the same object, and you get 1 either way.
Here’s the rule this whole article orbits: backup = player copies the wire, never the object on the other end of it.
Now, maybe something is already bugging you. You’ve copied plenty of variables before — numbers, strings — and nothing ever haunted you. Why does copying a number feel completely safe?
A plain number has no “inside.”
Let’s rewind to before objects entered the picture, back when copying felt honest. Here’s a score that isn’t inside an object — run the three lines and keep an eye out for wires:
Nothing has run yet.
No wires anywhere. Plain values travel by copy.
Did you spot the difference? The value sits right on the tag — no wire, no shared anything. Copying the tag copies the value, and after line 3, score holds a new 10 while savedScore calmly keeps its own 7. This is the happy world we all assumed we lived in.
There’s a deeper reason numbers behave, and it matters for everything ahead: you can replace a number, but you can’t open up the number 7 and edit a piece inside it. It has no inside.
Which other values behave like numbers?
Strings, booleans, null, undefined, bigints, and symbols behave this way too. JavaScript calls them primitive values.
They can’t be edited from the inside. That’s why a string method such as toLowerCase() hands you a brand-new string instead of changing the old one — and why you have to store its result somewhere.
But an object is different. An object does have an inside — and that gives JavaScript a second, sneakier kind of change.
These two lines do very different jobs.
Now that we can see wires, I can show you the distinction that took me months to notice. These two lines differ by six characters, and I honestly used to think they did the same thing. Run them and watch the wire:
score1score9Nothing has run yet.
Line 1 never touched the wire. It walked down it, opened the object at the far end, and edited the score sitting inside. That inside-edit has a name you’ll hear constantly: mutation — and because it happens to the object itself, every name wired to that object sees it. That’s exactly what stung us in the opening bug.
Line 2 is a replacement. It unhooked the wire and attached it to a brand-new object. Notice the old object in the board: nothing about it changed. It’s just no longer where this particular wire points.
Reassignment moves one name to a different object. Mutation reaches through every name wired to that object.
Wait—doesn’t const stop changes?
const stops the name from moving to a different object. It doesn’t freeze the object itself.
const player = { score: 0 };
player.score = 1; // allowed
player = { score: 1 }; // TypeError
The same is true for arrays: a const array can still use push(), but the variable can’t be replaced with another array.
Hold that difference in your head and think back to our “backup.” When my third line ran, which kind of change was it — and how many names could feel it?
Predict it before JavaScript answers.
Time to test the theory. Same setup as my bug, one small twist — and I want you to commit to an answer before you peek, because the guess is what makes the lesson stick.
const player = { score: 0 };
const backup = player;
backup.score++;
console.log(player.score);
The answer is 1. If you said 0, you answered exactly the way I did the first time — you trusted the backup. But both names are wired to the same object, and ++ mutates that object.
Now you’ve earned the official word for all of this. That invisible wire — the way a variable reaches one particular object — is what developers call a reference. When someone says “those two variables reference the same object,” they mean precisely what you just watched: two names, one object. (There are no literal wires inside your computer, of course — a reference is closer to an address the engine follows — but the wire picture predicts everything JavaScript will do with objects, and that’s all we need from it.)
So what would it actually take to free the two names from each other? Here’s the part that surprised me: nothing you do to the values inside helps. Run this one line and watch:
score1score5Both wires still end at the same object.
Only pointing a name at a different object makes two names independent.
Fine — when we write the mutation ourselves, at least it’s sitting right there on our screen. But what happens when a function does it somewhere we can’t see?
A function can change your object and return nothing.
Here’s an innocent-looking helper. It doesn’t return anything. It just “renames a player.” Make your prediction before you tap an answer.
function rename(currentPlayer) {
currentPlayer.name = "Lin";
}
const player = { name: "Ada" };
rename(player);
console.log(player.name);
It’s "Lin". The temporary name currentPlayer still reaches the same object as player.
This one bothered me for a long time, so let’s watch it under the X-ray. The function didn’t reach backward into my variable — nothing that dramatic happened. Step through the call:
inside rename()
name"Lin"The call hasn’t happened yet.
A parameter is a copied wire, not a copied object.
That’s the whole story: calling rename(player) copied the wire — exactly like backup = player did — and hung it on a temporary tag called currentPlayer. Two names, one object; one of the names just happens to live inside a function. And when the function returned, the tag vanished but the edit stayed, because the edit was never on the tag. It was in the object.
Ever since this clicked, I ask one question about every helper I hand an object to: does this function return a new value, or does it edit the one I gave it? That single question has saved me hours of debugging, and by the end of this page you’ll see it catch a real bug.
What if the function reassigns its parameter?
function rename(currentPlayer) {
currentPlayer = { name: "Lin" };
}
const player = { name: "Ada" };
rename(player);
console.log(player.name); // "Ada"
Reassignment only moves the function’s local name. The outside name player still reaches the original object.
Okay. So if = never copies the object, and functions quietly share it too — how do we make the real backup we wanted on day one?
Ask JavaScript to build a new object.
The fix turned out to be simple, once I understood the problem. I didn’t need a smarter = — I needed to explicitly ask JavaScript to build a second object. The spread syntax does exactly that. Run it and look for the moment a second box appears:
score1score0So far there is one object and one wire.
{ ...player } means “create a brand-new object, then copy player’s top-level properties into it.” For the first time in this whole story, player and checkpoint lead to different objects — and when line 2 changed one score, the other genuinely didn’t care. This is the backup I thought I was making on that painful afternoon.
How do I copy an array?
const scores = [30, 10, 20];
const copy = [...scores];
Object.assign({}, player) can copy an object, while scores.slice() can copy an array. The spelling differs; the job is still “make a new outer container.”
We finally made a second box. But I have to warn you about the trap I fell into the very next week: are all the boxes inside it new too?
Spread copies one level. Then it stops.
Feeling confident with my new spread trick, I copied a player whose stats lived in a nested object. You already know something’s coming — but predict exactly what:
const player = { stats: { name: "Ada" } };
const checkpoint = { ...player };
checkpoint.stats.name = "Lin";
console.log(player.stats.name);
The answer is "Lin" again. The outside was copied; the nested stats object was shared.
To see why, we need the X-ray one more time — because the wire that betrayed us is hiding inside the copy. Watch the stats property as the two lines run:
statsstatsname"Lin"player’s outer object holds a wire to the inner stats object.
Spread copied one level of tags and wires, then stopped.
There it is: a new outer shell whose stats property is — you guessed it — a copied wire, running straight to the same inner object as before. Developers call this a shallow copy, and it’s not a flaw; it’s the same rule from line 2 of our story, applied one level down.
The defense is honest work: copy every level along the path you plan to change.
const checkpoint = {
...player,
stats: {
...player.stats,
name: "Lin"
}
};
Should I use structuredClone instead?
structuredClone(value) can deeply copy supported data. It can’t copy every kind of JavaScript value, and for one focused update, copying only the changed path is often clearer.
One more question and the picture is complete. Two objects can now hold identical data. If you ask JavaScript whether they’re equal — what will it say?
JavaScript asks “same object?”—not “same contents?”
The first time I compared two identical-looking objects, the answer felt like another betrayal. Two objects, byte-for-byte the same data — and === says false. Trace it yourself:
score1score1a === btrue — same object
Two look-alike objects, two separate wires.
Watch what === actually did: it never opened the boxes to read their contents. It checked one thing — do these two wires end at the same object? Identical data, different objects: false. Then b = a swung the wire, and the very same comparison became true, even though no data changed anywhere.
That “which exact object is this?” quality has a name — identity — and it’s the last piece of the model. Equality of objects in JavaScript is about the wires, never the contents.
Why does this matter for event listeners?
Functions are objects too. Writing the same arrow function twice creates two different function objects.
const onClick = () => console.log("clicked");
document.addEventListener("click", onClick);
document.removeEventListener("click", onClick);
The browser can remove the listener because you give it the exact same function object. A new look-alike arrow function would not match.
You now have the whole mental model. So let me show you where I still see it fail in real code reviews — hiding inside a method name that sounds completely harmless.
A helper returns the right answer—and still breaks your data.
This is a real bug I’ve both written and approved in code review. It ships all the time, because every line of it looks correct:
function lowestScore(scores) {
scores.sort((a, b) => a - b);
return scores[0];
}
const leaderboard = [30, 10, 20];
const lowest = lowestScore(leaderboard);
lowest comes back as 10, which is exactly right. And yet somewhere else in the app, the leaderboard now displays [10, 20, 30] — reordered by a function that was only supposed to read it. You already have everything you need to narrate this crime. Watch it happen:
inside lowestScore()
items[10, 20, 30]The leaderboard is [30, 10, 20], in insertion order.
Arrays are objects, so the parameter scores is a wire to the caller’s array — and sort() mutates the object at the end of the wire. Remember the question from the rename section: “does this function edit the value I gave it?” Here it pays off. The fix is to sort a copy:
function lowestScore(scores) {
return [...scores].sort((a, b) => a - b)[0];
}
Now the copy gets rearranged, the original order survives, and nobody debugs a leaderboard that sorts itself.
map · filter · slice · concatpush · pop · splice · sort · reverseAnd that’s the ghost, fully unmasked. Nothing was ever haunted — there was only shared data, and names we didn’t realize were wired to the same thing.
When the bug returns, draw this.
Years later, I still meet this bug — in my code, in code reviews, in interview questions. What changed is that I no longer stare at the screen. I draw the picture below, put every variable on the left, every object on the right, and run the wires. The answer falls out every single time.
A number, string, or boolean
An object or array
Reassignment moves one name. Mutation changes the shared object.
Let’s say the whole story back in five sentences, because you’ve earned it. Assignment always copies what’s on the tag — and for an object, the tag holds a wire, not the object. So copying the tag gives you a second route to the same object, never a second object. A mutation happens to the object itself, which is why every name wired to it feels the change. And spread builds a new outer object while the deeper ones may quietly stay shared.
So when data ever changes “by itself,” don’t panic — ask the one question that matters: which names still reach the same object?
This is why React asks for new objects.
If you’re heading toward React or React Native, I’ll leave you with the payoff that surprised me most: React didn’t invent any new rules. It simply leans on object identity — the same === wire-check you watched two sections ago — as its clue that state changed.
// Same object: easy to miss
player.name = "Lin";
setPlayer(player);
// New object: clear change
setPlayer({ ...player, name: "Lin" });
The first version mutates the object React is already holding, so when React compares wires, nothing looks different and the screen may not update. The second version hands React a genuinely new object — a new identity — and the change is unmissable. That same identity check runs through dependency arrays, memoization, selectors, and caches.
You don’t need React to understand references. But I promise you the reverse: once references click, a surprising amount of React stops feeling like magic and starts feeling like the three lines we ran at the top of this page.