Essential Javascript Concepts
This tutorial will help to understand some essential JS concepts that in turn helps to strengthen the understanding of javascript in depth
Javascript is one of the most used programming language, one could even argue that there are no viable alternative for frontend programming to Javascript. Although, it is not the language everyone wants to work with, a wise one would keep their javascript skill sharp for unforeseeable future. A seasoned programmer can cruise through the basics of javascript, however, there are some concepts that may get overlooked. Most of the time, it is those overlooked concepts that are essential to begin understand any framework of a javascript in depth. Some of the undeniably important concepts are explained in this blog.
Hoisting
In Javascript, functions can be called before it is defined. In the example below sayHi()
is called in line 1 whereas it is defined in line 3, however, this works fine and produce an output.
sayHi()
function sayHi() {
console.log("Hi")
}
Remember, hoisting only works this way with functions. For variables, it behaves differently.
Why does hoisting work this way?
When JavaScript executes code, it first goes through a “compilation” phase before the actual “execution” phase. During compilation, JavaScript looks through the code for function declarations and “hoists” them to the top of their scope, initialising them before any other code executes.
This is why function can be called before even declaring in the code - by the time the execution phase starts, the function has already been initialised thanks to hoisting.
However, hoisting does not work the same way for variables declared with var
, let
, or const
. Only the declaration gets hoisted, not the initialisation.
Declaring variables
In Javascript, variables can be declared using var
or let
or const
keywords. var
is not a recommended way of declaring variable as it is hoisted to the top of the scope, if that’s not enough, the scope of such variable is function-level.
Recommended way of declaring variable would be to use let
keyword for a variable that needs to be updated, it does not create issues because its scope is block level ({}
curly braces defines boundary of a block). Additionally, we const
can be used to declare a variable, but once declared the value cannot be changed.
Explanation of issue caused by var
Let’s explore what issues it causes with the following example.
function fetchData() {
var urls = ['api/user/1', 'api/user/2', 'api/user/3'];
for (var i = 0; i < urls.length; i++) {
setTimeout(() => {
console.log(`Fetching data for: ${urls[i]}`);
}, 100);
}
}
fetchData();
Here the intended behaviour is the loop will go through each url and the setTimeout function will fetch data from each url with 100ms delay. However, the actual output will be
Fetching data for: undefined
The loop variable i
declared with var
keyword has function-level scope, i.e. can be modified anywhere inside fetchData
function. When the code executes, in each iteration it sends the variable i
inside the setTimeout
function which waits for 100ms. After 100ms, when it’s time to run the console.log
the value of i
is already updated to 3 as the loop continue to run updating the value of i
. Since, position 3 in the array urls
does not have any value it returns undefined
.
Arrow functions
There are many ways to write a function in Javascript, arrow function is a concise way of writing a function in javascript. It has the following syntax.
let arrowFunc = (arg1, arg2, ..., argN) => expression
the function can be called in similar manner as other function using its name and passing arguments as arrowFunc(arg1, arg2, ..., argN)
Benefit of arrow functions
Arrow functions provide a few key benefits over traditional function expressions:
- Implicit returns: If an arrow function has a single expression as its body, the expression result is implicitly returned (no
return
keyword needed).
let sum = (a, b) => a + b;
- Lexically bound
this
: Arrow functions do not have their ownthis
binding. Instead, they inherit thethis
value from the enclosing scope. This is often desirable behavior and can help avoid issues withthis
in callback functions.
const person = {
name: 'John',
greet: function() {
setTimeout(function() {
console.log(`Hello, my name is ${this.name}`); // `this` is `window`, not `person`
}, 1000);
}
};
const person2 = {
name: 'John',
greet: function() {
setTimeout(() => {
console.log(`Hello, my name is ${this.name}`); // `this` is `person2`
}, 1000);
}
};
Asynchronous programming
JavaScript is single-threaded, meaning it can only do one thing at a time. However, we often need to perform tasks that take some time to complete, like fetching data from an API or reading a file. We don’t want these slow operations to block the rest of our code from running. This is where asynchronous programming comes in.
Asynchronous programming allows us to start a long-running operation without waiting for it to finish. The code after the async operation continues to execute immediately. When the async operation eventually completes, a callback function is invoked to handle the result.
JavaScript provides a few ways to write asynchronous code:
- Callbacks: A callback is a function passed as an argument to another function. The callback will be executed after the outer function finishes. Callbacks can get messy if one would need to chain multiple async operations (known as callback hell).
fs.readFile('file.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
- Promises: A promise represents the eventual completion or failure of an async operation. Promises help avoid callback hell by allowing one to chain .then() calls.
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.log(error));
- Async/Await: Async/await builds on promises, allowing one to write async code that looks like synchronous code. An async function always returns a promise. The await keyword can only be used inside an async function.
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
} catch (error) {
console.log(error);
}
}
Which approach to choose?
While callbacks and promises are valid approaches to handling asynchronous operations, async/await has become the preferred method for a few reasons:
Readability: Async/await allows one to write asynchronous code that looks very similar to synchronous code. This makes the flow of the program easier to follow and reason about.
Error handling: With async/await, one can use regular
try
/catch
blocks to handle errors. This is much more intuitive than chaining.catch()
calls on promises.Concurrency: It’s easy to perform multiple asynchronous operations concurrently with
Promise.all()
andawait
:
async function fetchData() {
const [user, posts] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts')
]);
const userData = await user.json();
const postsData = await posts.json();
// Use userData and postsData
}
- Debugging: Debugging promises can be tricky because the debugger will not step over asynchronous code. With async/await, one can step through their asynchronous code line by line, just like synchronous code.
While async/await is built on top of promises, it provides a cleaner, more readable syntax and is generally easier to reason about. It’s a powerful tool for writing clear, maintainable asynchronous code.
Spreading object and arrays
The spread operator ...
allows us to expand an iterable (like an array or object) into its elements. This is very useful for cloning objects and arrays or merging them together.
// Copying an array
const original = [1, 2, 3];
const copy = [...original];
// Merging arrays
const arr1 = [1, 2];
const arr2 = [3, 4];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]
// Copying an object
const obj = { a: 1, b: 2 };
const objCopy = { ...obj };
// Merging objects
const obj1 = { a: 1 };
const obj2 = { b: 2 };
const mergedObj = { ...obj1, ...obj2 }; // { a: 1, b: 2 }
Destructuring object and arrays
Destructuring allows us to extract properties from objects or elements from arrays into distinct variables. This can greatly improve code readability.
// Object destructuring
const person = { name: 'John', age: 30, city: 'New York' };
const { name, age } = person;
console.log(name); // 'John'
console.log(age); // 30
// Array destructuring
const numbers = [1, 2, 3];
const [a, b] = numbers;
console.log(a); // 1
console.log(b); // 2
When to use destructuring?
Destructuring provides a few key benefits:
- Function parameters: Destructuring is often used to extract properties from objects passed as function parameters. This can make the function signature more clear and self-documenting.
// Without destructuring
function greet(person) {
console.log(`Hello, my name is ${person.name} and I'm ${person.age} years old.`);
}
// With destructuring
function greet({ name, age }) {
console.log(`Hello, my name is ${name} and I'm ${age} years old.`);
}
- Default values: One can provide default values for variables when destructuring, in case the property or element is undefined.
const { count = 0 } = {}; // count will be 0 if not present in the object
- Renaming variables: If one want to extract a property but the property name is already used for something else in their current scope, one can rename the variable.
const { name: firstName } = person; // `name` is extracted into a variable named `firstName`
- Nested destructuring: Destructuring can be nested to extract values from nested objects or arrays.
const person = {
name: 'John',
age: 30,
address: {
city: 'New York',
country: 'USA'
}
};
const { address: { city } } = person; // `city` is 'New York'