Recently, we rewrote our community blog for "Unicorn Utterances" to use Astro, a static site generator framework. One of the fan-favorite features of the site is it's dark mode toggle, which enables dark mode purists to gloat over the light mode plebians (like myself).
For real though, support light mode in your sites and make them the default setting - it's a major accessibility concern.
In the migration, I wrote some code to trigger a dark mode toggle:
// ...
const initialTheme = document.documentElement.className;
toggleButtonIcon(initialTheme);
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.className;
document.documentElement.className =
currentTheme === 'light' ? 'dark' : 'light';
const newTheme = document.documentElement.className;
toggleButtonIcon(newTheme);
})
While writing this, I thought:
That's an awful lot of
document.documentElement.className
repeated. What if we consolidated it to a single variable ofclassName
?
// ...
let theme = document.documentElement.className;
toggleButtonIcon(theme);
themeToggleBtn.addEventListener('click', () => {
theme = theme === 'light' ? 'dark' : 'light';
toggleButtonIcon(theme);
});
Awesome! Code looks a lot cleaner, now to go and test it...
Uh oh. It's not toggling anymore! 😱
Why did that code break? We made such a simple refactor?!
This migration of code broke our theme switching thanks to the underlying properties of "object mutation".
What's "object mutation"?
Let's talk about that. Along the way, we'll touch on:
- How memory addresses are stored in-memory
- The differences between
let
andconst
(including one you might not expect) - How to perform memory mutation
- How to fix our code
How variables are assigned to memory addresses
To understand object mutation, we first need to conceptualize how JavaScript handles variable creation.
In one of my blog posts called "Functions are values", I talk about how variables as stored into memory. In that article, I specifically talk about how, when you create JavaScript variables, they create a new space in memory.
Say that we wanted to initialize two variables:
var helloMessage = "HELLO";
var byeMessage = "SEEYA";
When we run this computer, it will create two "blocks" of memory to store these values into our RAM, the short-term memory of our computer. This might be visualized like so:
These memory blocks then can be referenced in our code by using their variable name (helloMessage
and byeMessage
respectively). It is important to note, however, a few things about these memory blocks:
1) They have a size.
Each of these memory blocks has an amount of system memory they consume. It can be a very small amount of data (like storing a small string as we're doing here), or a huge amount of data (such as keeping a movie's video file in memory)
2) They have a lookup address.
Very generally, this lookup address is simply the memory location of where a variable starts. This lookup address may be called a "memory address" and may be represented as a number of bits counting up from 0
.
These memory addresses can be re-used.
If explicitly told to, a computer can change the values of an existing memory block.These memory addresses/blocks can also be freed up when they're no longer needed.
In some languages, this is done manually while other languages do this (mostly) automatically.
5) Once freed, these memory addresses/blocks can be re-used.
Reassigning variables
Let's say we want to reassign the variable of helloMessage
to 'HEYYO'
:
var helloMessage = "HELLO";
var byeMessage = "SEEYA";
helloMessage = "HEYYO";
In this code sample, the first two lines:
1) Creates a helloMessage
variable.
- Which, in turn, creates a memory block (say,
0x7de35306
) - The characters that make up the string
"HELLO"
are placed in this memory block 2) Creates abyeMessage
variable. - This also creates a memory block (
0x7de35307
) - Which contains the string
"SEEYA"
After these two instructions are executed, we find ourselves reassigning the variable of helloMessage
to HEYYO
. While it might be reasonable to assume that the existing helloMessage
memory block is changed to reflect the new string, that's not the case.
Instead, the reassignment of helloMessage
creates a new memory block, adds it to the end of the memory stack, and removes the old memory block.
Once this is done, the helloMessage
variable points to a new memory address entirely, despite being called the same variable name.
This may seem unintuitive at first until we remember: memory addresses have sizes.
What does this mean for us?
Think about how the above memory is aligned in the above chart; Ideally, to utilize as much memory in your machine as possible, data should be side-by-side in your RAM. This means that if you have a memory address starting at memory address 10
and it takes up 13
bytes, the next memory address should start at 23
.
If you change the length of one memory block, you may have to shift over other blocks or rearrange their positioning. This can be a very expensive operation for your computer.
Since they're the same length strings, we could theoretically just reassign our HELLO
memory block to say HEYYO
. This isn't always the case, however, since strings can vary in length.
While HELLO
and HEYYO
are the same length, what happens if we tried to do this?
var helloMessage = "HELLO";
helloMessage = "THIS IS A LONG HELLO";
In this example we would need to create a new memory block anyway, since it doesn't hold the same value length as before.
But why doesn't our computer check if it's the same length before and decide to reassign the memory block or create a new one?
Well, as mentioned previously, your computer doesn't inherently know what the size of helloMessage
is. After all, the variable simply points to a memory address. To get the length of the memory block, you need to read the value and return the length to the rest of the computer.
So, in order to re-use existing blocks you would need to:
- Read the value of the existing
helloMessage
memory block. - Calculate the length of said memory block.
- Compare it against the new value's length.
- If they're the same, reuse the existing block.
- If not, then create a new block and cleanup the old one.
Remember, each of these executions takes time. While they're inexpensive on their own, if ran extremely frequently, even tiny fractions of time can add up.
Compare that list of 5 items against the "create a new block every time" implementation:
- Create a new block of memory and clean up the old one.
A much smaller list, right? This means that our computer is able to execute this faster than the other implementation.
let
vs const
If you've spent much time with the JavaScript ecosystem, you'll know that there are a few different ways of assigning a variable. Among these are the let
and const
keywords. Both of these are perfectly valid variable declarations:
const number = 1;
let otherNumber = 2;
But what's the difference between these two types of variables?
Now, you might assume that const
stands for constant
, and you'd be right! We can easily check this by running the following:
const val = 1;
val = 2;
Will yield you an error:
Uncaught TypeError: invalid assignment to const 'val'
This differs from the behavior of let
, which lets you reassign a variable's value to your heart's content:
let val = 1;
// This is valid
val = 2;
val = 3;
After seeing this behavior with const
, you might think that you can't change data within a const
, but (surprisingly), you'd be wrong. The following creates an object and then reassigns the value of one of the properties, despite the variable being a const
:
const obj = {val: 1};
// This is valid?! 😱
obj.val = 2;
Why is this? Isn't const
supposed to prevent reassignments of a variable?!
The reason we're able to change the value of obj.val
is because we're not reassigning the obj
variable; we're mutating it.
Variable Mutation
What is mutation?
Mutation is the act of replacing a variable's value in-place as opposed to changing the memory reference.
What. 😵💫
OK so picture this:
You have a string variable called "name" that has a memory address at 0x8f031e0a
.
let name = "Corbin"; // 0x8f031e0a
When you reassign this variable to "Crutchley", it will change the memory address, as we've established before:
name = "Crutchley"; // Changed to 0x8f031e0b
But what if, instead, you could simply tell JavaScript to change the value within the existing memory block instead:
// This code doesn't work - it's for demonstration purposes of what a theoretical JavaScript syntax could look like
const name = "Corbin"; // 0x8f031e0a
*name = "Crutchley"; // Still 0x8f031e0a
JavaScript could theoretically even use a syntax like this to expose a variable's memory address:
// This code doesn't work - it's for demonstration purposes of what a theoretical JavaScript syntax could look like
const name = "Corbin"; // A const string variable, creates a new memory block, but at what address?
// Outputs: `0x8f031e0a`
console.log(&name); // Prefixing & could show the memory address `name` was assigned to!
Some languages, such as Rust and C++ do have this feature, it's called a "pointer" and allows you to change the value of a memory block rather than create a new memory block with the new value you'd like to assign.
This is essentially what's happening with our const obj
mutation from the previous section. Instead of creating a new memory space for obj
, it's reusing the existing memory block it already has assigned to obj
and is simply changing the values within it.
// This creates a memory block to place `obj` into
const obj = {a: 123};
// This keeps the same memory block of "obj", but changes the value of "a" in place*
obj.a = 345;
There's one small problem with the example we used in this section, however; you cannot mutate strings.
const name = "Corbin";
// This does not work, and will throw an error
name = "Crutchley";
Why can't you mutate strings?
Consider what's happening inside of a JavaScript engine when we execute the following code:
const name = "Corbin";
In this code, we're creating a variable with the length of 6 characters. These characters are then assigned to a memory address, say, 0x8f031e0a
. Because your computer wants to preserve as much memory as possible, it will create a memory address just large enough for 6 characters to be stored in name
's memory block of 0x8f031e0a
.
Remember, while we tend to think of strings in JavaScript as a single value - not all strings have the same size when stored!
A string with a length of 6 characters is going to take up less space than a string with 900,000 characters.
Now, let's try to assign the string "Crutchley", which has a length of 9 characters, into that same memory block:
Oh no! Here, we can see that the new value we'd like to store is too large to exist in the current memory space!
This is the key reason we can't mutate strings like we can objects; To reuse an existing memory block, you have to make sure that the new value is the same size as the existing memory block, and strings cannot assure this truth like objects can.
This rule holds true for all JavaScript primitives as well.
Object Mutation
Wait, if you can't quickly change the size of a memory block, why can we mutate objects?
Well, you see, objects in JavaScript are typically* stored as a mapping of property names and the associated variable's memory address.
Image we have the object of user
like so:
{
firstName: "CORBIN",
lastName: "CRUTCHLEY"
}
This object might look something like the under-the-hood:
This means that when we change user.firstName
, we're actually constructing a new "hidden" variable, then assigning that new variable's memory address to the firstName
property on the object:
// This will create a new variable internally
// Then assign the new variables' version to the `user` field
user.firstName = "Cornbin";
By doing so, we're able to create new variables with different memory sizes but still keep the object's member location referentially stable.
This is a lot of handwaving going on and is more complex then this when adding or removing properties dynamically. The problem with getting more in-depth than this is that it gets complicated fast.
If you still want to learn more, I recommend checking out this deep dive in V8's (Chrome and Node.js's JS engine) internals.
Arrays are objects too!
It's worth highlighting that the same rules of object mutation apply to arrays as well! After all, in JavaScript arrays are a wrapper around the Object
type. We can see this by running typeof
over an array:
typeof []; // "object"
This means that we can run operations like push
that mutate our arrays, even with const
variables:
const arr = [];
// This is valid
arr.push(1);
arr.push(2);
arr.push(3);
// Even though this is not
const otherArr = [];
otherArr = [1, 2, 3];
Why did this impact our code?
Let's look back at the original problem this article posed. When we changed out code from this:
// ...
const initialTheme = document.documentElement.className;
toggleButtonIcon(initialTheme);
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.className;
document.documentElement.className =
currentTheme === 'light' ? 'dark' : 'light';
const newTheme = document.documentElement.className;
toggleButtonIcon(newTheme);
})
To this:
// ...
let theme = document.documentElement.className;
toggleButtonIcon(theme);
themeToggleBtn.addEventListener('click', () => {
theme = theme === 'light' ? 'dark' : 'light';
toggleButtonIcon(theme);
});
Our theme toggle sector broke. Why? Well, it has to do with object mutation.
When our original code did this:
document.documentElement.className = currentTheme === 'light' ? 'dark' : 'light';
We're explicitly telling document.documentElement
object map to change the variable location of className
.
However, when we changed this to:
let theme = document.documentElement.className;
// ...
theme = theme === 'light' ? 'dark' : 'light';
We're creating a new variable called theme
and changing the location of theme
based on the new value. Because className
is a string, which is a JavaScript primitive and not an object, it won't mutate document.documentElement
and therefore won't change the HTML
tag's class.
To solve this, we should revert our code to mutate docuemnt.documentElement
once again.
Conclusion
Hopefully this has been an insightful look into how JavaScript's let
, const
, and object mutations work.
If this article has been helpful, maybe you'd like my upcoming book called "The Framework Field Guide", which teaches React, Angular, and Vue all at once (for free!).
Either way, I hope you enjoyed the post and I'll see you next time!
Top comments (9)
Confusion about details is the second nature of Javascript. In languages like Pascal you can decide, if parameters are used "byReference" (=mutated in place) or "byValue" (copied before usage). This makes things much more clear. In Javascript, there are different implicit rules for that:
You have to be very clear about this concept to avoid errors.
With a very small modification, your code had worked as expected:
Yup, you got it! :) I know other languages have this distinction and admittedly wasn't too confused when I saw the bug, as I've been working with JS for a good while.
Mostly wanted to use it as a teaching aid for others.
Nitpick about variable naming -
document.documentElement
isn't aDocument
but rather aHTMLHtmlElement
(i.e. the<html>
element). A better name would be something likehtmlEl
orroot
.How do you call your dog? "Dog"?
Not sure what you mean. I wouldn't give my
Dog
aname
ofdog
, but for sure I'd name a variabledog
ifdog instanceof Dog
:It'd be pretty confusing if I wrote
const cat = new Dog('Fido')
instead.Ok, the whole discussion about variable naming is a bit off topic here. Maybe you think about what happens, if you have two dogs: 'Fido' and 'Bella'...
const dogs = [new Dog('Fido'), new Dog('Bella')]
😉Also that doesn't apply here. There can only ever be 1
documentElement
perdocument
, and it wouldn't make sense having multiple color-scheme toggles per top-levelwindow
anyway. Regardless, naming adocumentElement
asdoc
doesn't disambiguate with anything, it just creates further ambiguity withdocument
itself.If you needed to to disambiguate between
documentElement
s that belong to different iframes etc, you'd typically use names likeiframeRootEl
. It's pretty uncommon that you need to do that though, in my experience - usually code you're writing with will already be scoped to a single document, whether that'swindow.top.document
,iframe.contentDocument
, or something else.But you can have two dogs in your example... How do you name the variables then?
Really insightful post! It's very interesting how small changes to the code can have such a big impact on its functionality. Keep up the good work!