Promises & async/await
Every API call in a React app returns a Promise. Knowing how promises and async/await really behave is the difference between a clean data layer and a pile of unhandled errors.
Definition
A Promise is an object that stands for a value that is not ready yet. It is always in one of three states:
1pending → work is still running2fulfilled → finished with a value3rejected → failed with an errorOnce a promise is fulfilled or rejected it is settled, and it never changes again.
Analogy: a restaurant buzzer. You get it right away (the promise). Later it either lights up with your food (fulfilled) or tells you the kitchen ran out (rejected).
Using Promises
1fetch("/api/user")2 .then((res) => res.json()) // runs on success, returns a new promise3 .then((user) => console.log(user))4 .catch((err) => console.error(err)) // runs if ANY step above failed5 .finally(() => console.log("done")); // runs either wayEach .then returns a new promise, which is why you can chain them. Returning a value passes it to the next .then. Returning a promise makes the chain wait for it. A thrown error skips to the nearest .catch.
async / await
async/await is just a cleaner way to write the same promises.
1async function loadUser() {2 try {3 const res = await fetch("/api/user");4 if (!res.ok) throw new Error("HTTP " + res.status);5 return await res.json();6 } catch (err) {7 console.error(err);8 } finally {9 console.log("done");10 }11}Rules to remember:
- An
asyncfunction always returns a promise, even if you return a plain value. awaitpauses only that function, not the whole program. The rest of the code keeps running, and the code afterawaitresumes as a microtask.awaitcan only be used inside anasyncfunction, or at the top level of an ES module.
Gotcha 1: fetch Does Not Reject on HTTP Errors
Here is what breaks. A 404 or 500 response is still a successful fetch. The promise only rejects on network failure.
1const res = await fetch("/api/missing"); // resolves, even with status 4042if (!res.ok) throw new Error("Request failed: " + res.status); // you must checkGotcha 2: Sequential vs Parallel
1// Slow: second request waits for the first (about 2 seconds total)2const a = await getA();3const b = await getB();4 5// Fast: both start together (about 1 second total)6const [a, b] = await Promise.all([getA(), getB()]);If the two calls do not depend on each other, start them together.
Gotcha 3: await in a Loop and forEach
1items.forEach(async (item) => {2 await save(item); // ✗ forEach does not wait, the loop finishes immediately3});4 5for (const item of items) {6 await save(item); // ✓ runs one by one, in order7}8 9await Promise.all(items.map(save)); // ✓ runs all at onceGotcha 4: Unhandled Rejections
A rejected promise with no .catch and no try/catch causes an unhandled rejection. It can crash a Node process or show a console error in the browser. Always handle errors at the point where you can do something about them.
In React
You cannot make a useEffect callback itself async, because effects must return nothing or a cleanup function, not a promise.
1useEffect(() => {2 async function load() {3 const res = await fetch("/api/user");4 setUser(await res.json());5 }6 load(); // ✓ define an async function inside and call it7}, []);Also guard against stale responses and unmounted components using an AbortController or an ignore flag, which we cover in the data-fetching section. In practice, a library like TanStack Query handles most of this for you.
Summary
1Promise → a value that will exist later: pending, fulfilled or rejected2then / catch / finally → chain steps, errors skip to the nearest catch3async function → always returns a promise4await → pauses that function only, resumes as a microtask5fetch → rejects on network failure only, check res.ok yourself6Independent calls → Promise.all, not one await after another7forEach with await → does not wait, use for...of or Promise.all8useEffect → define an inner async function, never make the callback asyncWhen you have several promises at once, you need to choose how to combine them. That is next.