DEV Community

Mahbub Khandakar
Mahbub Khandakar

Posted on

Fetching Data in React: A Beginner-Friendly Guide (with `fetch` and `async/await`)

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));
Enter fullscreen mode Exit fullscreen mode

What's happening here?

  1. fetch(url) sends a network request and returns a Promise.
  2. response.json() parses the raw response body into a JavaScript object/array — this also returns a Promise, which is why we chain another .then().
  3. 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();
Enter fullscreen mode Exit fullscreen mode

Key points to explain to your juniors:

  • await can only be used inside a function marked async.
  • await "pauses" the function until the Promise resolves — no more nested .then() chains.
  • Always wrap it in try/catch so 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;
Enter fullscreen mode Exit fullscreen mode

Why we can't make useEffect's callback itself async

A common junior mistake:

// ❌ Don't do this
useEffect(async () => {
  const res = await fetch(...);
}, []);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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

  1. fetch() returns a Promise → chain .then() or use await.
  2. async/await + try/catch is cleaner and easier to reason about.
  3. In React, always fetch inside useEffect, never directly in the component body.
  4. Keep the async function separate — don't make the useEffect callback itself async.
  5. Always handle loading and error states, 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)