a.k.a. “pointers for JavaScript developers”
On Day 1 of learning to code, someone tells you “A variable is like a box. Writing thing = 5
puts 5
in the thing
box”. And that’s not really how variables work, but it’s good enough to get you going. It’s like in math class when they lie to you about the full picture, because the full picture would explode your brain right now.
Some time later though, you start to see weird problems. Variables changing when you didn’t change them. Ghosts in the machine.
“I thought I made a copy of that! Why did it change?” <– that right there is a reference bug!
By the end of this post, you’ll understand why that happens and how to fix it.
What is a Reference?
References are everywhere in JS, but they’re invisible. They just look like variables. Some languages, like C, call these things out explicitly as pointers, with their own syntax to boot. But JS doesn’t have pointers, at least not by that name. And JS doesn’t have any special syntax for them, either.
Take this line of JavaScript for example: it creates a variable called word
that stores the string “hello”.
let word = "hello"
Notice how word
points to the box with the “hello”. There’s a level of indirection here. The variable is not the box. The variable points to the box. Let that sink in while you continue reading.
Now let’s give this variable a new value using the assignment operator =
:
word = "world"
What’s actually happening here isn’t that the “hello” is being replaced by “world” – it’s more like an entirely new box is created, and the word
is reassigned to point at the new box. (and at some point, the “hello” box is cleaned up by the garbage collector, since nothing is using it)
If you’ve ever tried to assign a value to a function parameter, you probably realized this doesn’t change anything outside the function.
The reason this happens is because reassigning a function parameter will only affect the local variable, not the original one that was passed in. Here’s an example:
function reassignFail(word) {
// this assignment does not leak out
word = "world"
}
let test = "hello"
reassignFail(test)
console.log(test) // prints "hello"
Initially, only test
is pointing at the value “hello”.
Once we’re inside the function, though, both test
and word
are pointing at the same box.
After the assignment (word = "world"
), the word
variable points at its new value “world”. But we haven’t changed test
. The test
variable still points at its old value.
This is how assignment works in JavaScript. Reassigning a variable only changes that one variable. It doesn’t change any other variables that also pointed at that value. This is true whether the value is a string, boolean, number, object, array, function… every data type works this way.
Two Types of Types
JavaScript has two broad categories of types, and they have different rules around assignment and referential equality. Let’s talk about those.
Primitive Types in JavaScript
There are the primitive types like string, number, boolean (and also symbol, undefined, and null). These ones are immutable. a.k.a. read-only, can’t be changed.
When a variable holds one of these primitive types, you can’t modify the value itself. You can only reassign that variable to a new value.
The difference is subtle, but important!
Said another way, when the value inside a box is a string/number/boolean/symbol/undefined/null, you can’t change the value. You can only create new boxes.
It does not work like this…
This is why, for example, all of the methods on strings return a new string instead of modifying the string, and if you want that new value, you’ve gotta store it somewhere.
let name = "Dave"
name.toLowerCase();
console.log(name) // still capital-D "Dave"
name = name.toLowerCase()
console.log(name) // now it's "dave"
Every other type: Objects, Arrays, etc.
The other category is the object type. This encompasses objects, arrays, functions, and other data stuctures like Map and Set. They are all objects.
The big difference from primitive types is that objects are mutable! You can change the value in the box.
Immutable is Predictable
If you pass a primitive value into a function, the original variable you passed in is guaranteed to be left alone. The function can’t modify what’s inside it. You can rest assured that the variable will always be the same after calling a function – any function.
But with objects and arrays (and the other object types), you don’t have that assurance. If you pass an object into a function, that function could change your object. If you pass an array, the function could add new items to it, or empty it out entirely.
So this is one reason why a lot of people in the JS community try to write code in an immutable way: it’s easier to figure out what the code does when you’re sure your variables won’t change unexpectedly. If every function is written to be immutable by convention, you never need to wonder what will happen.
A function that doesn’t change its arguments, or anything outside of itself, is called a pure function. If it needs to change something in one of its arguments, it’ll do that by returning a new value instead. This is more flexible, because it means the calling code gets to decide what to do with that new value.
Recap: Variables Point to Boxes, and Primitives are Immutable
We’ve talked about how assigning or reassigning a variable effectively “points it at a box” that contains a value. And how assigning a literal value (as opposed to a variable) creates a new box and points the variable at it.
let num = 42
let name = "Dave"
let yes = true
let no = false
let person = {
firstName: "Dave",
lastName: "Ceddia"
}
let numbers = [4, 8, 12, 37]
This is true for primitive and object types, and it’s true whether it’s the first assignment or a reassignment.
We’ve talked about how primitive types are immutable. You can’t change them, you can only reassign the variable to something else.
Now let’s look at what happens when you modify a property on an object.
Modifying the Contents of the Box
We’ll start with a book
object representing a book in a library that can be checked out. It has a title
and an author
and an isCheckedOut
flag.
let book = {
title: "Tiny Habits",
author: "BJ Fogg",
isCheckedOut: false
}
Here’s our object and its values as boxes:
And then let’s imagine we run this code:
book.isCheckedOut = true
Here’s what that does to the object:
Notice how the book
variable never changes. It continues to point at the same box, holding the same object. It’s only one of that object’s properties that has changed.
Notice how this follows the same rules as earlier, too. The only difference is that the variables are now inside an object. Instead of a top-level isCheckedOut
variable, we access it as book.isCheckedOut
, but reassigning it works the exact same way.
The crucial thing to understand is that the object hasn’t changed. In fact, even if we made a “copy” of the book by saving it in another variable before modifying it, we still wouldn’t be making a new object.
let book = {
title: "Tiny Habits",
author: "BJ Fogg",
isCheckedOut: false
}
let backup = book
book.isCheckedOut = true
console.log(backup === book) // true!
console.log(backup.isCheckedOut) // also true!!
The line let backup = book
will point the backup
variable at the existing book object. (it’s not actually a copy!)
Here’s how that would play out:
The console.log
at the end further proves the point: book
is still equal to backup
, because they point at the same object, and because modifying a property on book
didn’t change the shell of the object, it only changed the internals.
Variables always point to boxes, never to other variables. When we assign backup = book
, JS immediately does the work to look up what book
points to, and points backup
to the same thing. It doesn’t point backup
to book
.
This is nice: it means that every variable is independent, and we don’t need to keep a sprawling map in our heads of which variables point to which other ones. That would be very hard to keep track of!
Mutating an Object in a Function
Wayyy back up in the intro I alluded to changing a variable inside a function, and how that sometimes “stays inside the function” and other times it leaks out into the calling code and beyond.
We already talked about how reassigning a variable inside a function will not leak out, as long as it’s a top-level variable like book
or house
and not a sub-property like book.isCheckedOut
or house.address.city
.
function doesNotLeak(word) {
// this assignment does not leak out
word = "world"
}
let test = "hello"
doesNotLeak(test)
console.log(test) // prints "hello"
And anyway, this example used a string, so we couldn’t modify it even if we tried. (because strings are immutable, remember)
But what if we had a function that received an object as an argument? And then changed a property on it?
function checkoutBook(book) {
// this change will leak out!
book.isCheckedOut = true
}
let book = {
title: "Tiny Habits",
author: "BJ Fogg",
isCheckedOut: false
}
checkoutBook(book);
Here’s what happens:
Look familiar? It’s the same animation from earlier, because the end result is exactly the same! It doesn’t matter whether book.isCheckedOut = true
occurs inside a function or outside, because that assignment will modify the internals of the book
object either way.
If you want to prevent that from happening, you need to make a copy, and then change the copy.
function pureCheckoutBook(book) {
let copy = { ...book }
// this change will only affect the copy
copy.isCheckedOut = true
// gotta return it, otherwise the change will be lost
return copy
}
let book = {
title: "Tiny Habits",
author: "BJ Fogg",
isCheckedOut: false
}
// This function returns a new book,
// instead of modifying the existing one,
// so replace `book` with the new checked-out one
book = pureCheckoutBook(book);
If you want to learn more about writing immutable functions like this, read my guide to immutability. It’s written with React and Redux in mind but most of the examples are plain JavaScript.
References in the Real World
With your newfound knowledge of references, let’s look at a few examples that could cause problems. See if you can spot the problem before reading the soltuion.
DOM Event Listeners
Quick background on how the event listener functions work: to add an event listener, call addEventListener
with the event name and a function. To remove an event listener, call the removeEventListener
with the same event name and the same function, as in the same function reference. (otherwise the browser can’t possibly know which function to remove, since an event can have multiple functions attached to it)
Have a look at this code. Is this using the add/remove functions correctly?
document.addEventListener('click', () => console.log('clicked'));
document.removeEventListener('click', () => console.log('clicked'));
…
…
…
…
Figured it out?
This code will never remove the event listener, because those two arrow functions are not referentially equal. They’re not the same function, even though they are identical as far as syntax goes.
Every time you write an arrow function () => { ... }
or a regular function function whatever() { ... }
, that creates a new object (functions are objects, remember).
Prove it! Try this in the console:
let a = () => {}
let b = () => {}
console.log(a === b)
It’ll print false
! Every new object (array, function, Set, Map, etc.) lives in a brand new box, unequal to every other box.
To make the event listener example work correctly, store the function in a variable first, and pass that same variable to both add and remove.
const onClick = () => console.log('clicked');
document.addEventListener('click', onClick);
document.removeEventListener('click', onClick);
Unintended Mutation
Let’s look at another one. Here’s a function that finds the smallest item in an array by sorting it first, and taking the first item.
function minimum(array) {
array.sort();
return array[0]
}
const items = [7, 1, 9, 4];
const min = minimum(items);
console.log(min)
console.log(items)
What does this print?
…
…
…
…
If you said 1
and [7, 1, 9, 4]
, you’re only half right ;)
The .sort()
method on arrays sorts the array in place, meaning it changes the order on the original array without copying it.
This example prints 1
and [1, 4, 7, 9]
.
Now, this might be what you wanted. But probably not, right? When you call a minimum
function, you don’t expect it to rearrange the items in your array.
This kind of behavior can be especially confusing when the function lives in another file, or in a library, where the code isn’t right in front of you.
To fix this, make a copy of the array before sorting it, like in the code below. Here we’re using the spread operator to make a copy of the array (the [...array]
part). This is actually creating a brand new array and then copying in every element from the old one.
function minimum(array) {
const newArray = [...array].sort();
return newArray[0]
}
Go Forth and Reference Well
This stuff comes up all the time, but it’s also one of those things you can kind of muddle through without knowing quite how it works.
It can take a little while to wrap your brain around the concept of “pointers”, variables pointing to values, and keeping references straight. If your brain feels like it’s in a fog right now, bookmark this article and come back in a week.
Once you get it, you’ve got it, and it’ll make all of your JS development go more smoothly.
This article is the first in a series on data structures and algorithms in JS. Next up is Linked lists in JavaScript! Now that you know how references work, linked lists will be way easier to understand.
I’m working on the next article in the series, on binary trees. Drop your email in the box if you want to be notified when it’s out!