Stack Overflow developer survey 2023 just dropped, and for the eleventh year in a row, JavaScript holds the top spot of the most commonly-used programming language. It's a breathtaking achievement, but it's hard to say that no one expected this. Statistics show that it is used on 98.7% of websites as a client-side technology nowadays. Moreover, it can take care of the server-side code as well, and by the way, Node.js ranks first among the most common web frameworks and technologies in the same survey.
Considering this, there's no wonder the number of JavaScript enthusiasts grows daily. However, mastering your craft as a JavaScript developer involves understanding things that lay beyond basic syntax. Today, we'll share some JavaScript concepts that can become a solid foundation for your future success.
Hoisting
Hoisting is a concept in JavaScript that can sometimes lead to unexpected results if developers are unfamiliar with it. It refers to the behavior where the JavaScript interpreter moves variable and function declarations to the top of their respective scopes (either function scope or global scope) before executing the code. As a result, you can call a function or access a variable before it is actually defined in the JavaScript code without encountering an 'Uncaught ReferenceError.'
Let's delve into this concept with an example:
hoistedFunction(); // This works even though the function is called before its definition
function hoistedFunction() {
console.log("Hello from the hoisted function!");
}
In this case, we call hoistedFunction() before its actual definition in the code. Surprisingly, it works without any errors due to hoisting, where the function declaration is moved to the top of the current scope by the JavaScript interpreter.
However, it's important to note that, in JavaScript, only the declarations are hoisted, not the initializations or assignments. Here’s another example:
console.log(hoistedVariable); // Output: undefined
var hoistedVariable = "I am hoisted";
console.log(hoistedVariable); // Output: I am hoisted
In this scenario, a variable hoistedVariable is accessed before its declaration. The first console.log() statement outputs undefined because the variable is hoisted to the top, but its value is not assigned yet. The second console.log() statement in this code outputs the assigned value "I am hoisted" after the variable is initialized.
Here’s a more complex example that uses hoisting to create a simple router for a web application:
router("/"); // This is the home page
router("/about"); // This is the “about” page
router("/contact"); // This is the “contact” page
router("/random"); // This page does not exist
function router(path) {
switch (path) {
case "/":
home();
break;
case "/about":
about();
break;
case "/contact":
contact();
break;
default:
notFound();
break;
}
}
function home() {
console.log("This is the home page");
}
function about() {
console.log("This is the about page");
}
function contact() {
console.log("This is the contact page");
}
function notFound() {
console.log("This page does not exist");
}
Here, the function declarations for router, home, about, contact, and notFound are defined before invoking the router function. This allows the functions to be hoisted to the top of their respective scopes, and they can be invoked before their actual definitions in the code.
Understanding hoisting helps developers write clear and maintainable code. While JavaScript hoists the declarations, it's considered a best practice to declare variables and functions at the top of their respective scopes to avoid confusion and potential bugs.
Additionally, it's worth noting that in JavaScript, let and const declarations are also hoisted, but unlike var declarations, they are not initialized with a default value of undefined. This behavior is known as the "temporal dead zone" and can lead to ReferenceErrors if developers try to access variables before their declarations in block scopes.
Closures
In JavaScript, closures are defined within other functions, allowing them to access variables from the outer function's scope. While the concept may seem simple, closures possess great power when developers write code that interacts with different scopes.
Read Also Top JavaScript Frameworks: From Industry Titans to Modest Hard Workers
The inner function, often referred to as the closure, can access variables from its own scope (between its curly brackets), as well as variables from its parent’s scope and global variables. It's important to note that, in JavaScript, the outer function cannot access the variables defined within the inner one:
function makeAdder(x) {
return function (y) {
return x + y;
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
In this JavaScript code, a function called makeAdder has parameter x, returns an inner function that takes another parameter y, and returns the sum of x and y. In this case, an inner function has access to variables defined in its outer function, even after the outer one has finished executing.
A more complex code example below shows how closures can be used to create a simple authentication system for a web application. Here an inner function (login and logout) retains access to variables from its outer function called auth even after the outer function has finished executing:
function auth() {
var currentUser = null; // only accessible within the function scope
return {
login: function(username, password) {
// method can access the currentUser variable
if (username === "admin" && password === "1234") {
currentUser = username;
console.log("Logged in as " + currentUser);
} else {
console.log("Invalid credentials");
}
},
logout: function() {
// This method can also access the currentUser variable
if (currentUser) {
console.log("Logged out from " + currentUser);
currentUser = null;
} else {
console.log("No user logged in");
}
}
};
}
// holds a reference to the object returned by the auth function
var adminSession = auth();
adminSession.login("admin", "1234"); // Logged in as admin
adminSession.login("user", "5678"); // Invalid credentials
adminSession.logout(); // Logged out from admin
adminSession.logout(); // No user logged in
Using Shorthands in JavaScript
Shorthands, in general, and arrow functions, in particular, provide developers with a concise syntax for making JavaScript code shorter and more readable:
// Regular Functions
function hello() {
return 'hello';
}
// Arrow Functions
const hello = () => 'hello';
In the above JavaScript code, both functions work the same way. However, you can see that the arrow function has a cleaner and shorter syntax. The empty parentheses () in the arrow function's syntax represent the arguments. Even if there are no arguments, these parentheses should be present.
Additionally, in JavaScript, if there is only one argument, developers can skip the parentheses altogether, like this:
const square = num => num * num;
Moreover, for one-liner arrow functions, you can skip the return statement. The expression after the arrow is automatically returned:
const double = num => num * 2;
In this JavaScript code, the arrow function double takes a number as an argument and returns its double without explicitly using the return keyword.
For more complex logic or multiple statements, developers can declare a multiline arrow function in their JavaScript code using curly braces {} similar to regular functions:
const square = num => {
const result = num * num;
return result;
};
It's important to note that arrow functions have a lexical this binding, meaning they inherit the this value from the enclosing scope. This behavior differs from regular functions, which have their own this value that is determined by the way they are called in the code. Described JavaScript functionality can be especially useful when developers work with higher-order functions, such as map, filter, and reduce, where the concise syntax is often preferred.
Destructuring
Destructuring in JavaScript allows developers to extract values from arrays or objects and assign them to variables concisely and flexibly. It provides a convenient way to unpack data structures and access their elements easily.
With array destructuring, developers can write JavaScript to extract values from an array and assign them to variables in a single line of code. The pattern of the destructuring assignment resembles the structure of the array:
const colors = ['red', 'green', 'blue'];
const [firstColor, secondColor, thirdColor] = colors;
console.log(firstColor); // Output: 'red'
console.log(secondColor); // Output: 'green'
console.log(thirdColor); // Output: 'blue'
In this JavaScript code, we have an array named colors with three elements. By using array destructuring, we assign the values of the array to the variables firstColor, secondColor, and thirdColor. Each variable receives the corresponding value from the array.
Destructuring also allows JavaScript developers to extract values from an object and assign them to variables with the same names as the object's properties:
const person = {
name: 'John Doe',
age: 30,
country: 'USA'
};
const { name, age, country } = person;
console.log(name); // Output: 'John Doe'
console.log(age); // Output: 30
console.log(country); // Output: 'USA'
And here’s how we can use destructuring to extract data from an API response and display it on a web page. Here, the fetch function returns a promise that resolves to a response object:
fetch("https://api.openweathermap.org/data/2.5/weather?q=London&appid=YOUR_API_KEY")
.then(function(response) {
// json method returns a promise that resolves to a data object
return response.json();
})
.then(function(data) {
// data object contains properties we can destructure and assign to variables
var {name, main: {temp, humidity}, weather: {description}} = data;
// use the variables to update the HTML elements on the web page
document.getElementById("city").textContent = name;
document.getElementById("temp").textContent = temp + " °C";
document.getElementById("humidity").textContent = humidity + " %";
document.getElementById("description").textContent = description;
})
.catch(function(error) {
// use the catch method to handle any errors that may occur
console.log(error.message);
});
Rest and Spread Operator
In JavaScript, developers use three dots for both rest and spread operators. However, they work differently.
The rest operator can be used in JavaScript code to gather multiple elements into an array. It allows you to represent an indefinite number of arguments as an array within a function or when destructuring arrays. The rest operator collects the remaining elements into a new array:
function sum(...numbers) {
return numbers.reduce((total, number) => total + number, 0);
}
console.log(sum(1, 2, 3, 4, 5)); // Output: 15
In the above JavaScript code example, the sum function uses the rest operator ...numbers in its parameter list. It allows sum to accept any number of arguments and gather them into an array named numbers. The reduce method is then used to calculate the sum of all the numbers.
Read Also The Best Time to Start Learning is Now. Five Full-stack Projects to Improve Your Skills in 2023
The spread, in its turn, is used to spread the elements of an array or object into individual elements. It is particularly useful when developers want to write JavaScript code for combining or cloning arrays, passing multiple arguments to functions, or creating new arrays or objects:
const numbers = [1, 2, 3];
const moreNumbers = [4, 5, 6];
const combined = [...numbers, ...moreNumbers];
console.log(combined); // Output: [1, 2, 3, 4, 5, 6]
Here, the spread operators ...numbers and ...moreNumbers are used within an array literal. This code spreads the elements of the numbers array and the moreNumbers array, combining them into a new array called combined.
You can use these operators, for example, for creating a simple shopping cart system for a web application. In the example below, the rest operator is used to collect arguments into an array in the add method, and the spread operator is used to expand an array into individual elements when adding products to the items array:
function cart() {
var items = [];
return {
add: function(...products) {
// rest collects the products into an array
// spread expands the array into individual elements and pushes them to the items array
items.push(...products);
console.log("Added " + products.length + " products to the cart");
},
checkout: function() {
var total = 0;
for (var item of items) {
total += item.price;
}
console.log("The total price is " + total + " $");
}
};
}
// reference to the object returned by the cart function
var myCart = cart();
myCart.add({name: "Book", price: 10});
myCart.add({name: "Pen", price: 1}, {name: "Notebook", price: 5});
myCart.checkout(); // The total price is 16 $
Promises
Promises are a powerful tool in modern JavaScript that allows developers to deal with the outcome of asynchronous operations, which can either be successful (resolved) or encounter an error (rejected) in JavaScript terminology.
A promise in JavaScript can be in one of three states:
Pending is the initial state when the final result is yet to be determined. The asynchronous operation is still in progress;
Resolved, aka fulfilled, signifies that the promise has been successfully resolved with a result. The asynchronous operation has completed successfully, and the promise holds the resolved value;
Rejected shows that the promise has encountered an error or has been explicitly rejected. The asynchronous operation has failed, and the promise contains the reason for the rejection.
Developers can utilize the .then(), .catch(), or finally() methods to handle the outcome of operations:
.then() is called when a promise is either resolved or rejected. It takes two callback functions as arguments. The first callback is executed after the successful resolution, receiving the resolved value as its argument. The second callback is optional and, in case of rejection, allows JavaScript developers to handle the error condition;
.catch() is specifically used in JavaScript as an error handler and is called in case of rejection or error during code execution. It provides developers with a convenient way to handle and process errors;
.finally() allows attaching a callback function to a promise that will be executed regardless of whether it is fulfilled or rejected. In other words, it lets you specify a piece of JavaScript code that should be executed after a promise settles, regardless of its outcome.
const fetchData = () => {
return new Promise((resolve, reject) => {
// Simulating an asynchronous operation
setTimeout(() => {
const data = 'Data successfully fetched';
// Simulating successful resolution
resolve(data);
// Simulating rejection
// reject(new Error('Failed to fetch data'));
}, 2000);
});
};
fetchData()
.then((result) => {
console.log(result); // Output: Data successfully fetched
})
.catch((error) => {
console.error(error); // Output: Failed to fetch data
});
In this example, we define a function fetchData() that returns a promise. We simulate an asynchronous operation inside the constructor using setTimeout(). If the operation succeeds, we call resolve() with the desired result. If not, we call reject() with an error object.
This feature provides developers a structured way to handle asynchronous operations and make JavaScript code more readable and maintainable. It plays a crucial role in modern JavaScript development, especially when working with APIs, making network requests, or dealing with time-consuming tasks.
In a more complex example, we’ll use promises to create a simple image loader for a web application. Here, the loadImage function takes a URL as an argument and returns a promise that resolves to an image element. Your homework for today is to add .catch to the second loadImage() call:
function loadImage(url) {
return new Promise(function(resolve, reject) {
var img = new Image();
// onload resolves the promise with the image element
img.onload = function() {
resolve(img);
};
// onerror rejects the promise with an error message
img.onerror = function() {
reject("Failed to load " + url);
};
// src sets the source of the image element
img.src = url;
});
}
// the catch method handles the rejection of the promise
loadImage("https://example.com/image1.jpg").then(function(img) {
document.body.appendChild(img);
}).catch(function(error) {
// the console.log method logs the error message
console.log(error); // Failed to load https://example.com/image1.jpg
});
// code chains multiple promises using the then method
loadImage("https://example.com/image2.jpg")
.then(function(img) {
document.body.appendChild(img);
return loadImage("https://example.com/image3.jpg");
})
.then(function(img) {
document.body.appendChild(img);
});
Async/await
The async/await functionality provides a more elegant and readable approach to working with Promises. While JavaScript is inherently synchronous, async/await allows us to write promise-based code in a way that appears synchronous, pausing the execution of further operations when needed.
Read Also The Difference Between Asynchronous and Synchronous Programming
To use async/await, JavaScript developers can prefix a function declaration with the magic word async. For example:
async function myAsyncFunction() {
// Code goes here
}
By adding async before a function you show that it will always return a Promise. It allows you to use the await keyword within the function to pause the execution of code until it is resolved or rejected. Note that you can only use await inside an async function:
async function asyncFunction() {
const promise = new Promise((resolve) => {
resolve('Promise resolved');
});
const response = await promise; // Execution is paused until the promise is resolved or rejected
return console.log(response);
}
asyncFunction(); // Output: Promise resolved
Here, inside the async function called asyncFunction(), we create a new promise and immediately resolve it with the value. Thanks to the await keyword in front of it, the execution of the code is paused until it’s resolved. After it’s resolved, the value is assigned to the response variable, and we log it to the console.
Async/await provides JavaScript developers a more linear way to handle asynchronous operations, making the code more intuitive. Under the hood, async functions still return promises, allowing developers to leverage their benefits, such as error handling using try/catch blocks. You may want to use async with all your functions, however, it won’t make much sense without using await inside them.
As a practical example, let’s see the code that uses async/await to create a simple currency converter for a web application. The convert function is declared as async and takes two arguments: the amount and the currency. The function returns a promise that resolves to the converted amount:
async function convert(amount, currency) {
// await pauses the execution until the fetch promise is resolved
var response = await fetch("https://api.exchangeratesapi.io/latest");
// await pauses the execution until the json promise is resolved
var data = await response.json();
/*
data contains exchange rates for different currencies:
data = {
rates: {
'USD': 2.7,
'XYZ': 10
}
}
*/
var rate = data.rates[currency];
// return value of the async function is wrapped in a promise
return amount * rate;
}
convert(100, "USD").then(function(result) {
console.log(result); // The converted amount in USD
});
// use the catch method to handle the rejection of the async function
convert(100, "XYZ").then(function(result) {
console.log(result); // This will not run
}).catch(function(error) {
console.log(error.message); // Cannot read property 'XYZ' of undefined
});
// use the await keyword inside async function to handle the async function
async function showConversion(amount, currency) {
try {
var result = await convert(amount, currency);
console.log(result);
} catch (error) {
console.log(error.message);
}
}
showConversion(100, "USD"); // The converted amount in USD
showConversion(100, "XYZ"); // Cannot read property 'XYZ' of undefined
Conclusions
These seven tips we gave you today can serve as a trampoline on your path toward the peaks of skill. Re-read them twice a day until you remember them and move forward, learning more complex concepts the world of JavaScript programming has to offer. It's constantly evolving, and no one knows what's waiting around the corner. No one, except our developers. They always keep their fingers on the pulse of life to ensure their skill set corresponds to the needs of the most demanding customers.