The last few years have shown us that web development is changing. As many features and APIs were being added to the browsers, we had to use them the right way. The language to which we owe this honor was JavaScript.
Initially, the developers were not convinced of how it was designed and had mostly negative impressions while using this script. Over time, it turned out that this language has great potential, and subsequent ECMAScript standards make some of the mechanics more human and, simply, better. In this article, we take a look at some of them.
Types of values in JS
The well-known truth about JavaScript is that everything here is an object. Really, everything: arrays, functions, strings, numbers, and even booleans. All types of values are represented by objects and have their own methods and fields. However, we can divide them into two categories: primitives and structurals. The values of the first category are immutable, meaning that we can reassign some variable with the new value but cannot modify the existing value itself. The second one represents values that can be altered, so they should be interpreted as collections of properties which we can replace or just call the methods which are designed to do it.
Scope of declared variables
Before we go deeper, let’s explain what the scope means. We can say that scope is the only area where we can use declared variables. Before the ES6 standard we could declare variables with the var statement and give them global or local scope. The first one is a realm that allows us to access some variables in any place of application, the second one is just dedicated to a specific area – mainly a function.
Since the standard ES2015, JavaScript has three ways to declare variables that differs with keyword. The first one is described before: variables declared by var keyword are scoped to the current function body. ES6 standard allowed us to declare variables with more-human ways – opposite to var statements, variables declared by const and let statements are scoped only to the block. However, JS treats the const statement quite unusually if compared to other programmeertalen – instead of a persisted value, it keeps a persisted reference to the value. In short, we can modify properties of an object declared with a const statement, but we can’t overwrite the reference of this variable. Some say, that the var alternative in ES6 is actually let statement. No, it is not, and the var statement is not and it probably won’t be ever withdrawn. A good practice is to avoid using var statements, because mostly they give us more trouble. In turn, we have to abuse const statements, until we have to modify its reference – then we should use let.
Example of unexpected scope behavior
Let’s start with the following code:
(() => {
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(`Value of "i": ${i}`);
}, 1000);
}
})();
When we look at it, it seems like the for loop iterates the i value and, after one second, it will log values of the iterator: 1, 2, 3, 4, 5. Well, it doesn’t. As we mentioned above, the var statement is about keeping the value of a variable for all the function body; it means that in the second, third and so on iteration the value of the i variable will be replaced with a next value. Finally, the loop ends and the timeout ticks show us the following thing: 5, 5, 5, 5, 5. The best way to keep a current value of the iterator is to use let statement instead:
(() => {
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(`Value of "i": ${i}`);
}, 1000);
}
})();
In the example above, we keep the scope of the i value in the current iteration block, it’s the only realm where we can use this variable and nothing cannot override it from outside this area. The result in this case is as expected: 1 2 3 4 5. Let’s take a look at how to handle this situation with a var statement:
(() => {
for (var i = 0; i < 5; i++) {
((j) => {
setTimeout(() => {
console.log(`Value of "j": ${j}`);
}, 1000);
})(i);
}
})();
As the var statement is about keeping the value inside the function block, we have to call a defined function which takes an argument – the value of the iterator’s current state – and then just do something. Nothing outside the declared function will override the j value.
Examples of wrong expectations of object values
The most often committed crime that I noticed regards ignoring the power of structurals and changing their properties which are also modified in other pieces of code. Take a quick look:
const DEFAULT_VALUE = {
favoriteBand: 'The Weeknd'
};
const currentValue = DEFAULT_VALUE;
const bandInput = document.querySelector('#favorite-band');
const restoreDefaultButton = document.querySelector('#restore-button');
bandInput.addEventListener('input', () => {
currentValue.favoriteBand = bandInput.value;
}, false);
restoreDefaultButton.addEventListener('click', () => {
currentValue = DEFAULT_VALUE;
}, false);
From the beginning: let’s assume that we have a model with default properties, stored as an object. We want to have a button that restores its input values to the default ones. After filling in the input with some values, we update the model. After a moment, we think that default pick was simply better, so we want to restore it. We click the button… and nothing happens. Why? Because of ignoring the power of referenced values.
This part: const currentValue = DEFAULTVALUE is telling the JS the following: take the reference to the DEFAULTVALUE value and assign currentValue variable with it. The real value is stored in the memory only once and both variables are pointing to it. Modifying some properties in one place means modifying them in another. We have a few ways to avoid situations like that. One that fulfills our needs is a spread operator. Let’s fix our code:
const DEFAULT_VALUE = {
favoriteBand: 'The Weeknd'
};
const currentValue = { ...DEFAULT_VALUE };
const bandInput = document.querySelector('#favorite-band');
const restoreDefaultButton = document.querySelector('#restore-button');
bandInput.addEventListener('input', () => {
currentValue.favoriteBand = bandInput.value;
}, false);
restoreDefaultButton.addEventListener('click', () => {
currentValue = { ...DEFAULT_VALUE };
}, false);
In this case, the spread operator works like this: it takes all the properties from an object and creates a new object filled with them. Thanks to this, the values in currentValue and DEFAULT_VALUE are no longer pointing to the same place in the memory and all changes applied to one of them won’t affect the others.
Ok, so the question is: is it all about using the magic spread operator? In this case – yes, but our models may require more complexity than this example. In case we use nested objects, arrays or any other structurals, the spread operator of top-level referenced value will only affect the top level and referenced properties will still be sharing the same place in memory. There are many solutions to handle this problem, all depends on your needs. We can clone objects in every depth level or, in more complex operations, use tools such as immer which allows us to write immutable code almost painlessly.
Mix it all together
Is a mix of knowledge about scopes and values types usable? Of course, it is! Let’s build something that uses both of these:
const useValue = (defaultValue) => {
const value = [...defaultValue];
const setValue = (newValue) => {
value.length = 0; // tricky way to clear array
newValue.forEach((item, index) => {
value[index] = item;
});
// do some other stuff
};
return [value, setValue];
};
const [animals, setAnimals] = useValue(['cat', 'dog']);
console.log(animals); // ['cat', 'dog']
setAnimals(['horse', 'cow']);
console.log(animals); // ['horse', 'cow']);
Let’s explain how this code works line by line. Well, the useValue function is creating an array based on defaultValue argument; it creates a variable and another function, its modificator. This modificator takes a new value which is in a tricky way applied to the existing one. At the end of function, we return the value and its modificator as array values. Next, we use the created function – declare animals and setAnimals as returned values. Use their modificator to check if the function affects the animal variable – yeah, it works!
But wait, what exactly is so fancy in this code? The reference keeps all the new values and you can inject your own logic into this modificator, such as some APIs of a part of the ecosystem that powers your dataflow with no effort. That tricky pattern is often used in more modern JS libraries, where the functional paradigm in programming allows us to keep the code less complex and easier to read by other programmers.
Samenvatting
The understanding of how language mechanics work under the hood allows us to write more conscious and lightweight code. Even if JS is not a low-level language and forces us to have some knowledge on how memory is assigned and stored, we still have to keep an eye out for unexpected behaviors when modifying objects. On the other hand, abusing clones of values is not always the right way and incorrect usage has more cons than pros. The right way of planning the dataflow is to consider what you need and what possible obstacles you can encounter when implementing the logic of the application.
Lees meer: