If you're learning React, one of the very first "real world" things you'll need to do is fetch data from an API and show it on the screen. In this post, we'll build that up step by step using a free, no-auth-required API: FakeStoreAPI.
By the end, you'll understand:
- How
fetch()works with.then() - How to rewrite the same logic using
async/await - How to fetch data properly inside a React component using
useEffect+useState - How to handle loading and error states like a pro
1. The Raw JavaScript Way — fetch() with .then()
Before React even enters the picture, let's understand plain JavaScript fetch.
fetch('https://fakestoreapi.com/products')
.then(response => response.json())
.then(data => console.log(data));
What's happening here?
-
fetch(url)sends a network request and returns a Promise. -
response.json()parses the raw response body into a JavaScript object/array — this also returns a Promise, which is why we chain another.then(). - The final
.then(data => ...)gives us the actual data (an array of products, in this case).
This works, but once you start adding error handling and multiple steps, .then() chains get messy fast. That's where async/await comes in.
2. The Cleaner Way — async/await
async/await is just syntactic sugar over Promises — it makes asynchronous code look synchronous, which is much easier to read.
async function getProducts() {
try {
const response = await fetch('https://fakestoreapi.com/products');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Something went wrong:', error);
}
}
getProducts();
Key points to explain to your juniors:
-
awaitcan only be used inside a function markedasync. -
await"pauses" the function until the Promise resolves — no more nested.then()chains. - Always wrap it in
try/catchso a failed request (bad internet, server down, 404, etc.) doesn't crash your app silently.
3. Now Let's Do It Inside React
In React, you can't just call fetch directly in the component body — that would run on every render, causing an infinite loop of requests. Instead, we use the useEffect hook to run the fetch once, when the component mounts, and useState to store the result.
import { useState, useEffect } from 'react';
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProducts = async () => {
try {
const response = await fetch('https://fakestoreapi.com/products');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setProducts(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchProducts();
}, []); // empty dependency array = run only once, on mount
if (loading) return <p>Loading products...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h2>Products</h2>
<ul>
{products.map(product => (
<li key={product.id}>
<strong>{product.title}</strong> — ${product.price}
</li>
))}
</ul>
</div>
);
}
export default ProductList;
Why we can't make useEffect's callback itself async
A common junior mistake:
// ❌ Don't do this
useEffect(async () => {
const res = await fetch(...);
}, []);
useEffect expects its callback to either return nothing or a cleanup function. If the callback is async, it implicitly returns a Promise, which React doesn't know how to handle — this triggers a warning and breaks the cleanup mechanism.
That's why we define fetchProducts as a separate async function inside useEffect, and just call it.
4. Breaking Down the Three States: Loading, Error, Success
This is the pattern your juniors will use in almost every real project:
| State | Purpose |
|---|---|
loading |
Show a spinner or "Loading..." text while waiting |
error |
Show a friendly error message if the request fails |
data (here, products) |
Show the actual UI once data arrives |
Encourage them to always think in terms of these three states — it prevents a lot of bugs like showing a blank screen or crashing on undefined.map().
5. Bonus: Fetching a Single Product
Once they're comfortable with the list, show them how to fetch a single item using a dynamic ID:
const fetchSingleProduct = async (id) => {
const response = await fetch(`https://fakestoreapi.com/products/${id}`);
const data = await response.json();
return data;
};
This sets them up nicely for the next lesson: routing + dynamic params (e.g., /products/:id with React Router).
6. Quick Recap for Your Session
-
fetch()returns a Promise → chain.then()or useawait. -
async/await+try/catchis cleaner and easier to reason about. - In React, always fetch inside
useEffect, never directly in the component body. - Keep the
asyncfunction separate — don't make theuseEffectcallback itselfasync. - Always handle
loadinganderrorstates, not just the success case.
Written while prepping to teach this exact flow to junior developers — feel free to steal this structure for your own session!
Top comments (0)