Before we start digging into the world of Promises, Fetch API, and related topics, it is important to understand the difference between synchronous (sync) and asynchronous (async) programming models.
A sync code is read and executed line by line, one after the other in order. It is a single-thread model that follows the code sequence strictly. While one operation is being performed the others that come next remain blocked, waiting for the previous operation to finish.
On the other hand, an async code is a multi-thread model, it doesn’t block further operations while others are being performed. To illustrate the difference between these two imagine we want to prepare a cup of coffee but in Javascript… bear with me.
If we decide to do this coffee synchronously we need to write the tasks in the same order in which they should be executed:
function putWaterToBoil (){
console.log('Boil the water.')
}
function getReadyToMakeCoffee(){
console.log('Grind coffee beans.')
console.log('Line the basket of your coffee maker with a filter.')
console.log('Wet the filter, and let it drain into your cup.')
console.log('Discard the wather in the cup.')
console.log('Measure the ground coffee into the wet filter.')
}
function makeCoffee(){
console.log('Pour water to wet the ground beans and drain into your cup.')
}
putWaterToBoil()
getReadyToMakeCoffee()
makeCoffee()
In this example, if my water takes 5 minutes to boil completely the other functions will have to wait until putWaterToBoil() ends before starting the next task.
Let’s prepare the coffee with JS again, using an asynchronous function. As soon as the water boils (5s later), the “pass the coffee” function performs:
function putWaterToBoil (){
console.log('Boil the water.')
setTimeout(() => {
console.log('We have boiled water')
makeCoffee()
}, 5000);
}
function getReadyToMakeCoffee(){
console.log('Grind coffee beans.')
console.log('Line the basket of your coffee maker with a filter.')
console.log('Wet the filter, and let it drain into your cup.')
console.log('Discard the wather in the cup.')
console.log('Measure the ground coffee into the wet filter.')
}
function makeCoffee(){
console.log('Pour water to wet the ground beans and drain into your cup.')
}
putWaterToBoil()
getReadyToMakeCoffee()
The part of the function putWaterToBoil(), that was outside the setTimeout, was executed synchronously, and the second part of the function (asynchronous) was on hold, while the function getReadyToMakeCoffee() was executed first.
Callback Hell
In our coffee-making example, we want certain tasks to happen in a specific order. For example, we can’t start making coffee before the water has boiled, and we can’t pour the water over the coffee grounds until the filter is ready. By nesting functions like getReadyToMakeCoffee
and makeCoffee
inside the function that boils the water, we can be sure that each step waits for the previous one to finish. So let’s refactor our code using a callback function to call the next step:
function putWaterToBoil(callback) {
console.log('Boil the water.');
setTimeout(() => {
console.log('We have boiled water');
callback();
}, 5000);
}
function getReadyToMakeCoffee(callback) {
console.log('Grind coffee beans.');
console.log('Line the basket of your coffee maker with a filter.');
console.log('Wet the filter, and let it drain into your cup.');
console.log('Discard the water in the cup.');
console.log('Measure the ground coffee into the wet filter.');
callback();
}
function makeCoffee(callback) {
console.log('Pour water to wet the ground beans and drain into your cup.');
callback();
}
putWaterToBoil(() => {
getReadyToMakeCoffee(() => {
makeCoffee(() => {
console.log('Your coffee is ready!');
});
});
});
While nesting functions give us control, it quickly becomes hard to manage by adding more and more tasks. As you saw in the example, each step depends on the previous one, and by increasing more steps, the code starts to “nest” deeper and deeper. It makes the code harder to read, debug, and maintain.
It is called Callback Hell. It occurs when we use callbacks excessively, ending up with several nested callbacks within callbacks, creating the “Pyramid of Doom” (due to its triangular shape).
Promises
Using Promises to flatten the structure, we avoid the Callback Hell and prevent the nesting.
A Promise is an object that represents the future outcome of an asynchronous operation, whether it succeeds or fails, and its returning value.
A Promise is useful because it allows asynchronous methods to return values like synchronous methods. Instead of giving the final result immediately, an asynchronous function returns a promise that provides the result afterward.
A Promise has three states:
- Pending: The initial state, before the operation completes.
- Fulfilled: The operation was successful, and the promise has a resolved value.
- Rejected: The operation failed, and the promise has a reason for the failure (usually an error).
A promise starts in a “pending” state and will eventually either succeed (fulfilled with a value) or fail (rejected with an error). When the promise finishes (either success or failure), any .then()
or .catch()
handlers you’ve added will run. If you attach a handler after the promise is finished, it will still run right away, so you don’t have to worry about missing the result, even if your code runs asynchronously.
Basically, promises make sure your success or error handling runs when it’s supposed to, without worrying about timing issues.
Syntax
new Promise(executor)
The executor is a function, it can receive two functions as parameters:
- Resolve function: It’s the parameter called to change the Promise status, which can be another Promise.
- Reject function: It changes the Promise status to rejected.
Any errors thrown in the executor
will cause the promise to be rejected, and the return value will be neglected.
new Promise((resolve, reject) => {})
Let’s go back to our coffee-making example. We can refactor our code to make it more readable and easier to maintain using promises:
function putWaterToBoil() {
console.log('Boil the water.');
return new Promise((resolve) => {
setTimeout(() => {
console.log('We have boiled water');
resolve();
}, 5000);
});
}
function getReadyToMakeCoffee() {
console.log('Grind coffee beans.');
console.log('Line the basket of your coffee maker with a filter.');
console.log('Wet the filter, and let it drain into your cup.');
console.log('Discard the water in the cup.');
console.log('Measure the ground coffee into the wet filter.');
}
function makeCoffee() {
console.log('Pour water to wet the ground beans and drain into your cup.');
}
putWaterToBoil()
.then(getReadyToMakeCoffee)
.then(makeCoffee)
.then(() => {
console.log('Your coffee is ready!');
})
.catch((error) => {
console.error('Something went wrong:', error);
});
The putWaterToBoil
function explicitly creates and resolves a promise after the water has boiled (5 seconds). Meanwhile, getReadyToMakeCoffee
and makeCoffee
are synchronous functions that do not return promises because they represent quicker, non-asynchronous actions. This works seamlessly because .then()
automatically wraps the return value of a function in a promise if it is not already one.
In this scenario, we chain the promises together using .then()
. This ensures that each step happens in order, but without nesting the functions, making the code much cleaner and easier to follow. A .catch()
block is added at the end to handle any errors during any steps.
This coffee-making example can be improved by allowing both the putWaterToBoil
and getReadyToMakeCoffee
functions to start simultaneously, rather than having one wait for the other to finish:
(...)
Promise.all([putWaterToBoil(), Promise.resolve(getReadyToMakeCoffee())])
.then(makeCoffe);
});
.then(() => {
console.log('Your coffee is ready!');
});
.catch((error) => {
console.error('Something went wrong:', error);
});
Including the asynchronous putWaterToBoil
and the synchronous getReadyToMakeCoffee
in the Promise.all()
array, we wrap it with Promise.resolve()
so it behaves like a promise.
Once both promises in Promise.all()
are resolved, the makeCoffee
function runs, ensuring that all the preparation steps are complete before brewing the coffee.
Illustration of Sequential vs. Parallel Execution in Promises
Async/Await
We can also use async and await to replace the then() usage. When the async is present it tells JavaScript that the function might contain asynchronous operations and will return a Promise, the await keyword pauses the execution of the function until the promise returned by the awaited function is resolved.
We can refactor the code with a new async function makeCoffeeRoutine():
async function makeCoffeeRoutine() {
try {
await putWaterToBoil();
await getReadyToMakeCoffee();
await makeCoffee();
console.log('Your coffee is ready!');
} catch (error) {
console.error('Something went wrong:', error);
}
makeCoffeeRoutine()
Fetch API
Now that we’ve seen how async
and await
work, let’s explore a common use case where these tools shine: making HTTP requests using the Fetch API.
Fetch is a promise-based JavaScript interface that allows us to interact with servers and APIs to retrieve data or send information.
With the Fetch API you can make HTTP requests by using the fetch() function and passing as an argument a string containing the URL to fetch. This function returns a promise that is fulfilled with an object that represents the server’s response. By combining Fetch with async
and await
, we can write clean, readable code to handle these asynchronous requests seamlessly.
async function getData() {
const response = await fetch("https://api.example.com/data");
const json = await response.json();
console.log(json);
}
Here, we use the fetch()
function to request data from a URL. We then use await
to wait for the promise to resolve, and we parse the response using .json()
. This approach allows us to write the code as if it were synchronous, making it much easier to read and maintain.
The fetch()
function will reject the promise on some errors, but not if the server responds with an error status like 404
: so it’s important to check the response status and throw an error if it is not OK. We can use try
and catch
for that:
async function getData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const json = await response.json();
console.log(json);
} catch (error) {
console.error(error.message);
}
}
useEffect
While we’ve seen how to handle asynchronous operations, things become more interesting when we incorporate these concepts into React components.
In React, fetching data or performing other asynchronous tasks often involves side effects, such as interacting with APIs or updating the DOM. To manage these side effects, React provides the useEffect
hook.
The useEffect
hook is designed to run code at specific points in a component’s lifecycle: when the component mounts (similar to componentDidMount in class components), when it updates due to changes in its props or state (similar to componentDidUpdate), and when it unmounts (similar to componentWillUnmount); making it very useful for tasks like fetching data when a component mounts or responding to changes in props, state, and all the variables and functions declared directly inside your component body.
The dependencies array is an optional second useEffect
parameter that lets you control when the effect should re-run. Let’s see how it works:
If you don’t include the dependencies array, the effect will run after every render:
useEffect(() => { console.log("Effect ran after every render") });
An empty array tells
useEffect
to run the effect only once, when the component mounts, perfect for fetching data when the component loads. The empty dependency array ([]) tells React that there are no dependencies that should trigger the effect, so it will not re-run unless the component unmounts and remounts:useEffect(() => { async function fetchData() { (...) } fetchData(); }, []);
When the array contains one or more variables, the effect will re-run whenever any of those variables change.
const [count, setCount] = useState(0); useEffect(() => { console.log(
Count updated to: ${count}
); }, [count]);
React uses referential equality to determine if the dependencies have changed. This means that if an object or function in the dependency array is recreated on each render, useEffect
will rerun.
Referential equality in the context of useEffect
refers to how React checks whether the values in the dependency array have changed between renders. The dependencies are checked using two different methods.
- For primitive values such as strings, numbers, and booleans, React compares their actual values. If the value changes (e.g.,
count
changes from1
to2
), the useEffect will rerun. - For non-primitive values such as objects, arrays, and functions, React compares their references in memory rather than their content. This means that even if two objects or functions contain the same data or logic, they are considered different if their references have changed, which can trigger the
useEffect
.
Using useEffect() with the Fetch API
By combining useEffect
with Fetch API, we can write efficient React components that handle async operations easily. In this example, we use the Fetch API combined with async/await
to retrieve data and update the component’s state:
import { useEffect, useState } from 'react';
function DataFetchingComponent() {
const [data, setData] = useState([]);
useEffect(() => {
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (error) {
console.error("Error fetching data:", error.message);
}
}
fetchData();
}, []);
return (
<div>
{data.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
Conclusion
Understanding asynchronous code is a game-changer for modern web development. It allows you to handle long-running actions without freezing your app, making everything smoother and more efficient. In this post, we explored some powerful tools to tackle async challenges—from escaping callback hell with Promises to simplifying async operations with async/await
, and using the Fetch API to effortlessly interact with servers.
To tie it all together, React’s useEffect
hook empowers you to synchronize components with external systems, making side effects like fetching data or reacting to state changes more manageable.
With these tools, you’re ready to write cleaner, and more efficient code. The next step? Dive in, experiment, and watch your coding skills level up!
Previously: React: First Puzzle Pieces — Your Easy-to-Follow Guide
This post is part of our ‘The Miners’ Guide to Code Crafting’ series, designed to help aspiring developers learn and grow. Stay tuned for more and continue your coding journey with us!! Check out the full summary here!
We want to work with you. Check out our "What We Do" section!