Async-Await + Promise.All + Callback

Using async/await and Promises in frontend React applications and backend services.

Use Case: Fetching Data from MongoDB

Let's say we have a React frontend that needs to fetch a list of users from a MongoDB database. We'll compare the use of async/await and Promises for both the frontend and backend.

Backend (MongoDB)

Using async/await

// api/getUsers.js

const { MongoClient } = require('mongodb');

const uri = process.env.MONGO_URI;
let client;

async function connectToDatabase() {
    if (!client) {
        client = new MongoClient(uri, {
            useNewUrlParser: true,
            useUnifiedTopology: true,
        });
        await client.connect();
    }
    return client.db('mydatabase');
}

module.exports = async (req, res) => {
    try {
        const db = await connectToDatabase();
        const users = await db.collection('users').find({}).toArray();
        res.status(200).json(users);
    } catch (error) {
        res.status(500).json({ error: 'Failed to fetch users' });
    }
};

Using Promises

// api/getUsers.js

const { MongoClient } = require('mongodb');

const uri = process.env.MONGO_URI;
let client;

function connectToDatabase() {
    if (!client) {
        client = new MongoClient(uri, {
            useNewUrlParser: true,
            useUnifiedTopology: true,
        });
        return client.connect().then(() => client.db('mydatabase'));
    }
    return Promise.resolve(client.db('mydatabase'));
}

module.exports = (req, res) => {
    connectToDatabase()
        .then((db) => db.collection('users').find({}).toArray())
        .then((users) => res.status(200).json(users))
        .catch((error) => res.status(500).json({ error: 'Failed to fetch users' }));
};

Frontend (React)

Using async/await

// components/UserList.js

import React, { useState, useEffect } from 'react';

const UserList = () => {
    const [users, setUsers] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        const fetchUsers = async () => {
            try {
                const response = await fetch('/api/getUsers');
                const data = await response.json();
                setUsers(data);
            } catch (error) {
                console.error('Failed to fetch users', error);
            } finally {
                setLoading(false);
            }
        };

        fetchUsers();
    }, []);

    if (loading) {
        return <p>Loading...</p>;
    }

    return (
        <ul>
            {users.map((user) => (
                <li key={user._id}>{user.name}</li>
            ))}
        </ul>
    );
};

export default UserList;

Using Promises

// components/UserList.js

import React, { useState, useEffect } from 'react';

const UserList = () => {
    const [users, setUsers] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        fetch('/api/getUsers')
            .then((response) => response.json())
            .then((data) => {
                setUsers(data);
                setLoading(false);
            })
            .catch((error) => {
                console.error('Failed to fetch users', error);
                setLoading(false);
            });
    }, []);

    if (loading) {
        return <p>Loading...</p>;
    }

    return (
        <ul>
            {users.map((user) => (
                <li key={user._id}>{user.name}</li>
            ))}
        </ul>
    );
};

export default UserList;

Comparison

Readability

  • async/await: Generally easier to read and understand, especially for those familiar with synchronous code. It makes the code look cleaner and less nested, which improves maintainability.

  • Promises: Can become harder to read and maintain due to nested .then and .catch blocks, especially in complex scenarios.

Error Handling

  • async/await: Using try/catch blocks allows for more straightforward error handling.

  • Promises: Error handling with .catch is effective but can lead to nested code, making it harder to follow.

Debugging

  • async/await: Easier to debug since it appears more synchronous, and stack traces are more straightforward.

  • Promises: Can be harder to debug due to the asynchronous nature and chaining, which can make stack traces more complex.

Performance

  • Both: Performance-wise, there is no significant difference between async/await and Promises. Both are built on top of the same underlying mechanisms.

Conclusion

While both approaches are valid, async/await tends to offer better readability and maintainability, especially as the complexity of your code increases. For simple use cases, Promises might be sufficient, but for more complex logic and error handling, async/await is generally preferred.


Sure! Promise.all is useful when you need to perform multiple asynchronous operations concurrently and wait for all of them to complete before proceeding. Let's extend our use case to demonstrate this. Assume that in addition to fetching users, we also need to fetch posts and comments, and we want to display all this information together in our React component.

Backend (MongoDB)

First, we’ll set up our backend to handle fetching users, posts, and comments.

Using async/await with Promise.all

// api/getData.js

const { MongoClient } = require('mongodb');

const uri = process.env.MONGO_URI;
let client;

async function connectToDatabase() {
    if (!client) {
        client = new MongoClient(uri, {
            useNewUrlParser: true,
            useUnifiedTopology: true,
        });
        await client.connect();
    }
    return client.db('mydatabase');
}

module.exports = async (req, res) => {
    try {
        const db = await connectToDatabase();
        
        const [users, posts, comments] = await Promise.all([
            db.collection('users').find({}).toArray(),
            db.collection('posts').find({}).toArray(),
            db.collection('comments').find({}).toArray(),
        ]);
        
        res.status(200).json({ users, posts, comments });
    } catch (error) {
        res.status(500).json({ error: 'Failed to fetch data' });
    }
};

Frontend (React)

Now, we’ll update our React component to fetch and display users, posts, and comments together.

Using async/await with Promise.all

// components/DataList.js

import React, { useState, useEffect } from 'react';

const DataList = () => {
    const [data, setData] = useState({ users: [], posts: [], comments: [] });
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch('/api/getData');
                const result = await response.json();
                setData(result);
            } catch (error) {
                console.error('Failed to fetch data', error);
            } finally {
                setLoading(false);
            }
        };

        fetchData();
    }, []);

    if (loading) {
        return <p>Loading...</p>;
    }

    return (
        <div>
            <h2>Users</h2>
            <ul>
                {data.users.map((user) => (
                    <li key={user._id}>{user.name}</li>
                ))}
            </ul>

            <h2>Posts</h2>
            <ul>
                {data.posts.map((post) => (
                    <li key={post._id}>{post.title}</li>
                ))}
            </ul>

            <h2>Comments</h2>
            <ul>
                {data.comments.map((comment) => (
                    <li key={comment._id}>{comment.text}</li>
                ))}
            </ul>
        </div>
    );
};

export default DataList;

What is callback?

A callback is a function that is passed as an argument to another function and is executed after some event or operation has completed. Callbacks are used to handle asynchronous operations in JavaScript, allowing you to continue executing code while waiting for the asynchronous operation to finish.

Example of Callbacks

Here's a basic example to illustrate how callbacks work:

function fetchData(callback) {
    setTimeout(() => {
        const data = { id: 1, name: 'John Doe' };
        callback(data);
    }, 1000); // Simulating an asynchronous operation with setTimeout
}

function handleData(data) {
    console.log('Data received:', data);
}

fetchData(handleData);

In this example:

  • fetchData is a function that takes a callback function as an argument.

  • Inside fetchData, we simulate an asynchronous operation using setTimeout.

  • After the operation completes (after 1 second), the callback function is called with the data.

Callbacks:

  • Readability: Can become difficult to read and maintain, especially with nested callbacks, leading to "callback hell".

  • Error Handling: Each callback needs to handle errors individually, which can scatter error handling logic.

  • Flow Control: Managing the flow of multiple asynchronous operations can become complex.

Promises:

  • Readability: Easier to read and manage than callbacks, especially with chaining.

  • Error Handling: Centralized error handling using .catch().

  • Flow Control: More straightforward than callbacks, but can still become complex with deeply nested .then() chains.

async/await:

  • Readability: The most readable and cleanest approach, resembling synchronous code.

  • Error Handling: Centralized error handling using try/catch.

  • Flow Control: Simplifies the flow of asynchronous operations, making it easier to follow.

What to do if I want to hit multiple Api's ?

When you need to hit multiple APIs concurrently, you can use Promise.all with async/await. Promise.all allows you to execute multiple asynchronous operations in parallel and wait for all of them to complete. Here's how you can achieve this:

Example: Fetching Data from Multiple APIs

Assume you need to fetch user data, post data, and comment data from three different API endpoints.

Backend (Node.js with async/await and Promise.all)

javascriptCopy code// api/getData.js

const fetch = require('node-fetch'); // or use axios

const API_USER = 'https://api.example.com/users';
const API_POST = 'https://api.example.com/posts';
const API_COMMENT = 'https://api.example.com/comments';

module.exports = async (req, res) => {
    try {
        const [usersResponse, postsResponse, commentsResponse] = await Promise.all([
            fetch(API_USER),
            fetch(API_POST),
            fetch(API_COMMENT),
        ]);

        if (!usersResponse.ok || !postsResponse.ok || !commentsResponse.ok) {
            throw new Error('Failed to fetch data from one or more APIs');
        }

        const [users, posts, comments] = await Promise.all([
            usersResponse.json(),
            postsResponse.json(),
            commentsResponse.json(),
        ]);

        res.status(200).json({ users, posts, comments });
    } catch (error) {
        res.status(500).json({ error: 'Failed to fetch data' });
    }
};

Frontend (React Component with async/await and Promise.all)

javascriptCopy code// components/DataList.js

import React, { useState, useEffect } from 'react';

const DataList = () => {
    const [data, setData] = useState({ users: [], posts: [], comments: [] });
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch('/api/getData');
                const result = await response.json();
                setData(result);
            } catch (error) {
                console.error('Failed to fetch data', error);
            } finally {
                setLoading(false);
            }
        };

        fetchData();
    }, []);

    if (loading) {
        return <p>Loading...</p>;
    }

    return (
        <div>
            <h2>Users</h2>
            <ul>
                {data.users.map((user) => (
                    <li key={user.id}>{user.name}</li>
                ))}
            </ul>

            <h2>Posts</h2>
            <ul>
                {data.posts.map((post) => (
                    <li key={post.id}>{post.title}</li>
                ))}
            </ul>

            <h2>Comments</h2>
            <ul>
                {data.comments.map((comment) => (
                    <li key={comment.id}>{comment.text}</li>
                ))}
            </ul>
        </div>
    );
};

export default DataList;

How It Works

Backend

  • Fetching Data Concurrently:

    • The fetch function is used to make HTTP requests to different API endpoints.

    • Promise.all is used to send requests to all three APIs concurrently.

    • The first Promise.all waits for all fetch requests to complete, and then we check if all responses are successful.

    • The second Promise.all converts the responses to JSON concurrently.

  • Error Handling:

    • If any of the fetch requests fail, an error is thrown and caught by the catch block, returning a 500 status with an error message.

Frontend

  • Fetching Combined Data:

    • The fetchData function is called inside a useEffect hook to fetch data from the backend when the component mounts.

    • The data received from the backend API is stored in the state and used to render the UI.

    • Loading state management ensures the UI displays a loading message until all data is fetched and ready.

Benefits of Using Promise.all

  1. Concurrency: Executes multiple asynchronous operations in parallel, making the process faster than executing them sequentially.

  2. Error Handling: If any of the promises reject, Promise.all immediately rejects with that reason, making it easier to handle errors.

  3. Simplified Code: Using Promise.all with async/await keeps the code clean and readable, avoiding deeply nested callbacks or multiple .then chains.

Conclusion

By leveraging async/await with Promise.all, you can efficiently handle multiple concurrent asynchronous operations. This approach is particularly useful when fetching data from multiple APIs, ensuring your application remains performant and your codebase stays maintainable.

Last updated