DEV Community

Cover image for A story of let, const, object mutation, and a bug in my code
Corbin Crutchley for This is Learning

Posted on • Originally published at unicorn-utterances.com

A story of let, const, object mutation, and a bug in my code

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);
})
Enter fullscreen mode Exit fullscreen mode

While writing this, I thought:

That's an awful lot of document.documentElement.className repeated. What if we consolidated it to a single variable of className?

// ...
let theme = document.documentElement.className;
toggleButtonIcon(theme);

themeToggleBtn.addEventListener('click', () => {
  theme = theme === 'light' ? 'dark' : 'light';

  toggleButtonIcon(theme);
});
Enter fullscreen mode Exit fullscreen mode

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 and const (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";
Enter fullscreen mode Exit fullscreen mode

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:

A big block called "memory" with two items in it. One of them has a name of "helloMessage" and is address  raw `0x7de35306` endraw  and the other is "byeMessage" with an address of  raw `0x7de35306` endraw .

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.

  1. These memory addresses can be re-used.
    If explicitly told to, a computer can change the values of an existing memory block.

  2. 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";
Enter fullscreen mode Exit fullscreen mode

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 a byeMessage 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";
Enter fullscreen mode Exit fullscreen mode

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:

  1. Read the value of the existing helloMessage memory block.
  2. Calculate the length of said memory block.
  3. Compare it against the new value's length.
  4. If they're the same, reuse the existing block.
  5. 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:

  1. 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When you reassign this variable to "Crutchley", it will change the memory address, as we've established before:

name = "Crutchley"; // Changed to 0x8f031e0b
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Why can't you mutate strings?

Consider what's happening inside of a JavaScript engine when we execute the following code:

const name = "Corbin";
Enter fullscreen mode Exit fullscreen mode

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:

The "Corbin" value of the "name" variable only takes up "6 blocks" but the value of "Crutchley" would take up 9

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"
}
Enter fullscreen mode Exit fullscreen mode

This object might look something like the under-the-hood:

The object "user" acts  as a container of memory addresses which are associated with property names. These memory addresses can change the associated value without changing the object's size

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";
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

To this:

// ...
let theme = document.documentElement.className;
toggleButtonIcon(theme);

themeToggleBtn.addEventListener('click', () => {
  theme = theme === 'light' ? 'dark' : 'light';

  toggleButtonIcon(theme);
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
efpage profile image
Eckehard • Edited

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:

  • Strings or numbers are always used "byValue" -> copied at allocation
  • Objects and arrays are used "byReference" -> mutated in place

You have to be very clear about this concept to avoid errors.

With a very small modification, your code had worked as expected:

// ...
let doc = document.documentElement; //-- get object reference
toggleButtonIcon(doc.className);

themeToggleBtn.addEventListener('click', () => {
  doc.className= doc.className=== 'light' ? 'dark' : 'light'; // mutate in place

  toggleButtonIcon(doc.className);
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
crutchcorn profile image
Corbin Crutchley

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.

Collapse
 
lionelrowe profile image
lionel-rowe

Nitpick about variable naming - document.documentElement isn't a Document but rather a HTMLHtmlElement (i.e. the <html> element). A better name would be something like htmlEl or root.

Collapse
 
efpage profile image
Eckehard • Edited

How do you call your dog? "Dog"?

Dog

Thread Thread
 
lionelrowe profile image
lionel-rowe

Not sure what you mean. I wouldn't give my Dog a name of dog, but for sure I'd name a variable dog if dog instanceof Dog:

class Dog {
    constructor(name) {
        this.name = name
    }
}

const dog = new Dog('Fido')
console.log(dog.name) // Fido
Enter fullscreen mode Exit fullscreen mode

It'd be pretty confusing if I wrote const cat = new Dog('Fido') instead.

Thread Thread
 
efpage profile image
Eckehard

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'...

Thread Thread
 
lionelrowe profile image
lionel-rowe

const dogs = [new Dog('Fido'), new Dog('Bella')] 😉

Also that doesn't apply here. There can only ever be 1 documentElement per document, and it wouldn't make sense having multiple color-scheme toggles per top-level window anyway. Regardless, naming a documentElement as doc doesn't disambiguate with anything, it just creates further ambiguity with document itself.

If you needed to to disambiguate between documentElements that belong to different iframes etc, you'd typically use names like iframeRootEl. 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's window.top.document, iframe.contentDocument, or something else.

Thread Thread
 
efpage profile image
Eckehard

But you can have two dogs in your example... How do you name the variables then?

Collapse
 
coolplaydev profile image
Coolplay Development

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!