DEV Community

Cover image for Say goodbye to callback hell
Phuoc Nguyen
Phuoc Nguyen

Posted on • Originally published at phuoc.ng

Say goodbye to callback hell

JavaScript Proxy can help solve the issue of callback hell. Let me show you what I mean with an example. Imagine a user trying to log in to a web application. The back-end has to verify the user's username and password, but that's just the beginning. Depending on the application's requirements, there are many other actions the back-end could take:

  • Grant access to certain features or functionalities based on the user's role or permissions.
  • Log the user's access for auditing purposes or to track usage statistics.
  • Store a session token for the user so they don't have to keep logging in repeatedly.
  • Check whether this is their first login attempt, and if so, show them an introduction tour of your app's features.

But that's not all! You could also offer them a discount or promotion if they're a new user or if they've completed a certain action within your app. Plus, you could track their behavior and use that data to improve your app's functionality or suggest personalized content in the future. The possibilities are endless!

What is callback hell?

Let's simplify the issue by focusing on the first two actions we introduced earlier. To implement these actions, we might introduce a service called UserService that supports various functions for performing different actions. For example, to verify a user's username and password, we could use the following function:

verifyUser(username, password, (error, userInfo) => {
    ...
});
Enter fullscreen mode Exit fullscreen mode

The function I'm about to explain has three parameters. The first two are the username and password. The third parameter is a callback that has two parameters of its own.

The first parameter of the callback, error, tells us if there was an issue verifying the user. For example, if the username doesn't exist or if the password is invalid. The second parameter of the callback, userInfo, contains the user's information if we find it in our database.

We can use this same approach to create other similar functions for our service.

getUserRoles(username, (error, roles) => {
    ...
});

logAccess(username, (error) => {
    ...
});
Enter fullscreen mode Exit fullscreen mode

To verify the user, we can use the following sample code:

const verifyUser = (username, password, callback) => {
    userService.verifyUser(username, password, (error, userInfo) => {
        if (error) {
            callback(error);
        } else {
            userService.getUserRoles(username, (error, roles) => {
                if (error) {
                    callback(error);
                }else {
                    userService.logAccess(username, (error) => {
                        if (error){
                            callback(error);
                        } else {
                            callback(null, userInfo, roles);
                        }
                    });
                }
            });
        }
    });
};
Enter fullscreen mode Exit fullscreen mode

When we call the verifyUser function, it goes through several steps. First, it checks if the username and password are valid by calling the userService.verifyUser function. If there's an error, it immediately calls the callback with the error message.

Assuming the username and password are valid, it moves on to retrieve all roles associated with the user by calling userService.getUserRoles. If there's an error retrieving the roles, it immediately calls the callback with that error message.

Once all roles have been successfully retrieved, it logs the login attempt by calling userService.logAccess. This step also has the potential to throw errors, which will be caught and passed back through the callback.

Finally, if everything goes smoothly up until this point, it returns the user's information along with their roles by calling back with null as the first parameter and userInfo and roles as subsequent parameters.

However, this implementation of verifyUser suffers from callback hell. It's hard to read and follow, and it's difficult to maintain and debug. Nested callbacks make it especially tricky to handle errors, and it's not very modular. If we need to add more functionality later on, we risk breaking other parts of our codebase.

To address these issues, we can use JavaScript proxy. Proxies allow us to intercept calls to objects and add custom behavior before forwarding them on to the original object. This lets us keep our code modular while still being able to add new functionality as needed.

In the next section, we'll explore how to use proxies to improve the verifyUser function.

Simplifying callbacks with JavaScript Proxy

Callback functions can make our code difficult to read and maintain. Luckily, there's a solution: JavaScript Proxy. By using a Proxy object, we can intercept and customize operations on objects. This allows us to simplify our code and avoid "callback hell".

To illustrate, let's rewrite the verifyUser function using Proxy. We'll wrap our userService object and add a layer of abstraction that handles the callbacks for us. This will make our code cleaner and easier to read.

Here's an example implementation using a Proxy:

const userServiceProxy = new Proxy(userService, {
    get: (target, propKey) => {
        const originalMethod = target[propKey];
        return (...args) => {
            return new Promise((resolve, reject) => {
                originalMethod(...args, (error, result) => {
                    if (error) {
                        reject(error);
                    } else {
                        resolve(result);
                    }
                });
            });
        };
    },
});
Enter fullscreen mode Exit fullscreen mode

In this implementation, we use a proxy object to intercept method calls on the userService object. This adds an extra layer of abstraction that handles callbacks.

When you call the get method on the userServiceProxy object, it returns a new Promise that wraps around the original method call on the userService object. This Promise resolves with the result of the method call if there are no errors, and rejects with an error message if there are.

So, when you call a method on the proxy object, it returns a Promise that either resolves or rejects based on the original callback from the userService method.

To put it simply, we're rewriting the user verification process as follows:

const verifyUser = async function(username, password) {
    try {
        const userInfo = await userServiceProxy.verifyUser(username, password);
        const roles = await userServiceProxy.getUserRoles(username);
        await userServiceProxy.logAccess(username);
        return [userInfo, roles];
    } catch (error) {
        throw error;
    }
};
Enter fullscreen mode Exit fullscreen mode

The updated version of the verifyUser function is a game-changer! We've simplified the code and eliminated those frustrating nested callbacks by using async/await. Now, when we call verifyUser, it returns a Promise that either resolves with an array containing the user information and roles (if everything goes well), or throws an exception that we can catch using a try/catch block if there's an error.

Here's how it works: first, we call userServiceProxy.verifyUser with the provided username and password as arguments. If successful, this returns a Promise that resolves with the user information. Next, we call userServiceProxy.getUserRoles with the same username argument, which returns a Promise that resolves with an array of roles for that user. Finally, we call userServiceProxy.logAccess, which logs access for auditing purposes.

If anything goes wrong during any of these operations, an error will be thrown and caught by the catch block at the end of the function. This means we can handle errors gracefully without crashing our application.

With this new implementation of verifyUser, we can finally say goodbye to callback hell and write cleaner, more efficient code with better error handling.

Conclusion

To sum up, callback hell is a common issue in JavaScript that can make code hard to read and maintain. Fortunately, we can use JavaScript Proxy to simplify our code and get rid of nested callbacks. By wrapping our service methods with a Proxy object, we can handle callbacks using Promises instead. This lets us write cleaner, more readable code that's easier to maintain and troubleshoot. With this approach, we can improve our applications' quality and reduce the time spent debugging issues caused by callback hell.


If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

If you want more helpful content like this, feel free to follow me:

Top comments (0)