intro-frontend-course

Lecture 2: Asynchronous JavaScript and Promises

Understanding Asynchronous JavaScript

In JavaScript, asynchronous operations allow code to run without blocking the execution of other operations. This is crucial for tasks like fetching data from a server, handling user inputs, or managing timers.

JavaScript is single-threaded, meaning it can execute one piece of code at a time. However, it uses the event loop to handle asynchronous operations, enabling the browser to perform other tasks while waiting for an operation to complete.

The Event Loop

The event loop is a fundamental part of JavaScript’s runtime model that handles asynchronous operations. It continuously checks the call stack and message queue to determine what code to execute next.

  1. Call Stack: This is where the current execution context resides. It contains the functions that are being executed.
  2. Message Queue: This is where messages (events or callbacks) are queued waiting to be processed.

Real-World Scenario: Web Browser Handling Events

Imagine a web browser where users can interact with a webpage by clicking buttons, submitting forms, and scrolling. The event loop allows the browser to handle these interactions asynchronously, ensuring the page remains responsive.

Example: Event Loop in Action

console.log('Start');

setTimeout(() => {
  console.log('This is a timeout callback');
}, 1000);

console.log('End');

// Output:
// Start
// End
// This is a timeout callback

In this example, the setTimeout function schedules a callback to be executed after 1000 milliseconds. The event loop ensures that the callback is placed in the message queue and executed after the call stack is empty.

For an in-depth explanation, refer to MDN Web Docs: Asynchronous JavaScript.

Synchronous vs. Asynchronous Execution

Synchronous Code runs sequentially, meaning each operation must complete before the next one starts.

console.log('Start');
console.log('Middle');
console.log('End');
// Output: Start, Middle, End

Asynchronous Code allows certain operations to occur without waiting for others to complete.

console.log('Start');
setTimeout(() => console.log('Middle'), 1000);
console.log('End');
// Output: Start, End, Middle

Events and the Event Loop

JavaScript uses an event-driven model where various events (like user interactions or network responses) can trigger asynchronous operations. These events are handled by the event loop.

Real-World Scenario: Button Click Event

document.getElementById('myButton').addEventListener('click', () => {
  console.log('Button was clicked!');
});

When the button is clicked, an event is generated and placed in the message queue. The event loop then processes this event and executes the associated callback function.

Example: Event Loop with Multiple Events

console.log('Start');

document.getElementById('myButton').addEventListener('click', () => {
  console.log('Button was clicked!');
});

setTimeout(() => {
  console.log('Timeout callback');
}, 1000);

console.log('End');

// Output:
// Start
// End
// (After 1 second) Timeout callback
// (After button click) Button was clicked!

In this example, both the setTimeout and click event callbacks are placed in the message queue and processed by the event loop after the synchronous code execution completes.

Callbacks

A callback is a function passed into another function as an argument, which is then invoked inside the outer function to complete some kind of routine or action.

Real-World Example: Fetching User Data

function fetchUserData(userId, callback) {
  setTimeout(() => {
    const user = { id: userId, name: 'John Doe' };
    callback(user);
  }, 1000);
}

fetchUserData(1, (user) => {
  console.log(user); // { id: 1, name: 'John Doe' }
});

While callbacks are useful, they can lead to callback hell if not managed properly. This makes code hard to read and maintain.

Real-World Scenario: Nested Callbacks in File Processing

Imagine you have a series of file processing tasks that depend on the previous task’s completion. Using nested callbacks can quickly become unmanageable:

processFile1((result1) => {
  processFile2(result1, (result2) => {
    processFile3(result2, (result3) => {
      console.log('All files processed:', result3);
    });
  });
});

Promises

Promises are objects representing the eventual completion or failure of an asynchronous operation. They provide a cleaner, more intuitive way to handle async operations compared to callbacks.

A promise can be in one of three states:

Real-World Example: Fetching User Data with Promises

const fetchUserData = (userId) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const user = { id: userId, name: 'John Doe' };
      resolve(user);
    }, 1000);
  });
};

fetchUserData(1).then((user) => {
  console.log(user); // { id: 1, name: 'John Doe' }
}).catch((error) => {
  console.error(error);
});

For more details, see MDN Web Docs: Promises.

Real-World Scenario: Handling API Responses

Using promises makes it easier to handle API responses and errors:

fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => console.error('Fetch error:', error));

Applying promises during a fetch call

Chaining Promises

Promises can be chained to handle multiple asynchronous operations sequentially.

Real-World Example: Chaining Promises for Sequential Operations

const fetchUserData = (userId) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const user = { id: userId, name: 'John Doe' };
      resolve(user);
    }, 1000);
  });
};

fetchUserData(1)
  .then((user) => {
    console.log(user); // { id: 1, name: 'John Doe' }
    return fetchUserData(2); // Chain another fetch
  })
  .then((user) => {
    console.log(user); // { id: 2, name: 'Jane Doe' }
  })
  .catch((error) => {
    console.error(error);
  });

Real-World Scenario: Sequential Data Fetching

Imagine you need to fetch user details and then fetch their posts:

const fetchUser = (userId) => fetch(`https://jsonplaceholder.typicode.com/users/${userId}`).then(response => response.json());
const fetchPosts = (userId) => fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`).then(response => response.json());

fetchUser(1)
  .then(user => {
    console.log('User:', user);
    return fetchPosts(user.id);
  })
  .then(posts => {
    console.log('Posts:', posts);
  })
  .catch(error => {
    console.error('Error:', error);
  });

Async/Await

Async/await is a syntax for writing asynchronous code in a more synchronous fashion. It is built on top of promises.

Real-World Example: Fetching User Data with Async/Await

const fetchUserData = (userId) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const user = { id: userId, name: 'John Doe' };
      resolve(user);
    }, 1000);
  });
};

async function getUserData() {
  try {
    const user = await fetchUserData(1);
    console.log(user); // { id: 1, name: 'John Doe' }
  } catch (error) {
    console.error(error);
  }
}

getUserData();

Async functions always return a promise. Await pauses the execution of the async function until the promise is settled.

For further reading, check JavaScript.info: Async/await.

Real-World Scenario: Simplified API Requests

Using async/await simplifies chaining multiple API requests:

async function fetchUserAndPosts(userId) {
  try {
    const userResponse = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
    const user = await userResponse.json();
    
    const postsResponse = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
    const posts = await postsResponse.json();
    
    console.log('User:', user);
    console.log('Posts:', posts);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchUserAndPosts(1);

Practical Examples

Example 1: Fetching Data from an API

Using fetch and promises to get data from a public API.

fetch

('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

Using async/await for the same task.

async function fetchTodo() {
  try {
    let response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchTodo();

Example 2: Handling Multiple Asynchronous Operations

async function fetchMultipleData() {
  try {
    let [todo1, todo2] = await Promise.all([
      fetch('https://jsonplaceholder.typicode.com/todos/1').then(response => response.json()),
      fetch('https://jsonplaceholder.typicode.com/todos/2').then(response => response.json())
    ]);
    console.log(todo1, todo2);
  } catch (error) {
    console.error('Error:', error);
  }
}

fetchMultipleData();

Example 3: Real-World Scenario: Displaying User Data

Combining everything to fetch and display user data from an API.

const fetchUser = async (userId) => {
  try {
    let response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
    if (!response.ok) {
      throw new Error('Network response was not ok ' + response.statusText);
    }
    let user = await response.json();
    console.log(`User: ${user.name}, Email: ${user.email}`);
  } catch (error) {
    console.error('Fetch error:', error);
  }
};

fetchUser(1);

Video Resources:

Understanding asynchronous JavaScript and mastering promises and async/await syntax will significantly enhance your ability to write efficient, non-blocking code.