Practical Examples

10. Practical Examples: Combining Events, Stacks, and Queues

In this section, we’ll bring together the concepts of events, stacks, and queues in JavaScript to see how they work together in real-world scenarios. By understanding how these components interact, you’ll be better equipped to write efficient, non-blocking code and troubleshoot common issues in asynchronous programming.

Example 1: Event Handling with Callbacks and the Event Loop

Let’s start with a practical example that involves event handling, the call stack, and the callback queue.

Scenario: You have a button on a web page that, when clicked, triggers a series of asynchronous tasks. The tasks involve logging a message, fetching data from an API (simulated with a setTimeout), and updating the DOM with the result.

Example Code:

javascript
	document.getElementById('myButton').addEventListener('click', function () {
	  console.log('Button clicked!');
	
	  setTimeout(() => {
	    console.log('Fetching data...');
	
	    setTimeout(() => {
	      console.log("Data fetched: { name: 'Lily' }");
	      document.getElementById('result').innerText = 'Name: Lily';
	    }, 2000);
	  }, 1000);
	
	  console.log('End of click handler');
	});

Explanation:

  • When the button is clicked, the click event listener is triggered, and the corresponding function is pushed onto the call stack.
  • The function logs Button clicked! and End of click handler synchronously.
  • The setTimeout function schedules the first asynchronous task (logging “Fetching data…”) to run after 1 second, placing its callback in the callback queue.
  • After 1 second, the first timeout callback logs “Fetching data…” and schedules another timeout for fetching the data, which logs Data fetched: { name: 'Lily' } after 2 more seconds and updates the DOM.
  • The output will be:
txt
	Button clicked!
	End of click handler
	Fetching data... (after 1 second)
	Data fetched: { name: 'Lily' } (after 2 more seconds)

This example shows how asynchronous tasks are queued and processed in order, demonstrating the interaction between the call stack, event loop, and callback queue.

Example 2: Using Promises to Handle Asynchronous Operations

In this example, we’ll replace setTimeout with promises to handle asynchronous operations more effectively. Promises offer a cleaner way to manage asynchronous tasks, particularly when dealing with complex chains of operations.

Scenario: You want to fetch data asynchronously and then update the DOM with the fetched data, but this time using promises instead of setTimeout.

Example Code:

javascript
	function fetchData() {
	  return new Promise((resolve) => {
	    console.log('Fetching data...');
	    setTimeout(() => {
	      resolve({ name: 'Lily' });
	    }, 2000);
	  });
	}
	
	document.getElementById('myButton').addEventListener('click', function () {
	  console.log('Button clicked!');
	
	  fetchData().then((data) => {
	    console.log('Data fetched:', data);
	    document.getElementById('result').innerText = `Name: ${data.name}`;
	  });
	
	  console.log('End of click handler');
	});

Explanation:

  • The fetchData function returns a promise that resolves with the fetched data after 2 seconds.
  • The click event handler logs “Button clicked!” and “End of click handler” immediately.
  • The fetchData function is called, and the returned promise is handled using then, which logs the fetched data and updates the DOM.
  • The output will be:
txt
	Button clicked!
	End of click handler
	Fetching data...
	Data fetched: { name: "Lily" } (after 2 seconds)

This example highlights how promises simplify asynchronous code by avoiding deeply nested callbacks (callback hell) and making the code more readable.

Example 3: Handling Multiple Asynchronous Tasks with Promise.all

Sometimes, you need to handle multiple asynchronous tasks concurrently and perform an action only after all tasks have completed. The Promise.all method is ideal for this scenario.

Scenario: You want to fetch user details and user posts concurrently, and then update the DOM once both requests are complete.

Example Code:

javascript
	function fetchUserDetails() {
	  return new Promise((resolve) => {
	    console.log('Fetching user details...');
	    setTimeout(() => {
	      resolve({ id: 1, name: 'Lily' });
	    }, 2000);
	  });
	}
	
	function fetchUserPosts() {
	  return new Promise((resolve) => {
	    console.log('Fetching user posts...');
	    setTimeout(() => {
	      resolve([{ postId: 1, content: 'Hello World!' }]);
	    }, 3000);
	  });
	}
	
	document.getElementById('myButton').addEventListener('click', function () {
	  console.log('Button clicked!');
	
	  Promise.all([fetchUserDetails(), fetchUserPosts()]).then(([userDetails, userPosts]) => {
	    console.log('User details:', userDetails);
	    console.log('User posts:', userPosts);
	    document.getElementById('result').innerText = `
	                Name: ${userDetails.name}, Posts: ${userPosts.length}`;
	  });
	
	  console.log('End of click handler');
	});

Explanation:

  • Two asynchronous tasks (fetchUserDetails and fetchUserPosts) are executed concurrently using Promise.all.
  • The Promise.all method waits for both promises to resolve before executing the then block, ensuring that the DOM is only updated once all data is available.
  • The output will be:
txt
	Button clicked!
	End of click handler
	Fetching user details...
	Fetching user posts...
	User details: { id: 1, name: "Lily" } (after 2 seconds)
	User posts: [{ postId: 1, content: "Hello World!" }] (after 3 seconds)

This example demonstrates how to handle multiple asynchronous operations concurrently and efficiently.

Example 4: Debugging Asynchronous Code with the Call Stack

Understanding the call stack is crucial for debugging asynchronous JavaScript code. In this example, we’ll see how to trace the execution flow when an error occurs.

Scenario: You have a series of asynchronous tasks, and an error occurs in one of them. You want to trace the error back to its source.

Example Code:

javascript
	function fetchData() {
	  return new Promise((resolve, reject) => {
	    console.log('Fetching data...');
	    setTimeout(() => {
	      reject(new Error('Failed to fetch data'));
	    }, 2000);
	  });
	}
	
	document.getElementById('myButton').addEventListener('click', function () {
	  console.log('Button clicked!');
	
	  fetchData()
	    .then((data) => {
	      console.log('Data fetched:', data);
	      document.getElementById('result').innerText = `Name: ${data.name}`;
	    })
	    .catch((error) => {
	      console.error('Error:', error.message);
	    });
	
	  console.log('End of click handler');
	});

Explanation:

  • The fetchData function simulates a failed asynchronous operation by rejecting the promise after 2 seconds.
  • The error is caught in the catch block, which logs the error message to the console.
  • The output will be:
txt
	Button clicked!
	End of click handler
	Fetching data...
	Error: Failed to fetch data (after 2 seconds)

In the browser’s developer tools, you would see the stack trace of the error, allowing you to trace back through the function calls to identify the source of the problem.

Summary:

These practical examples illustrate how events, stacks, and queues work together in JavaScript to manage asynchronous tasks. By combining these concepts, you can write more efficient, responsive, and maintainable code. Understanding the interaction between the event loop, callback queue, microtask queue, and the call stack is key to mastering asynchronous programming in JavaScript.

In the next section, we’ll recap the key concepts covered in this course and provide some final thoughts and tips for further learning.