Skip to content

React Data Fetching: Loading, Errors, & Custom Hooks

Posted on:January 22, 2026 at 10:00 AM

Hey there, Fellow Coder! 👋

So you’ve mastered useState and useEffect. Now you want to build a real app. And real apps need real data. Usually, this data lives on a server somewhere, and we need to go get it.

”Easy!” you say. “I’ll just use fetch!”

Well, yes. But if you just slap a fetch inside a component, you’re inviting chaos. What if the internet is slow? What if the server explodes? What if the user leaves the page before the data arrives?

Today, we’re going to learn how to fetch data professionally. Not just getting the data, but handling the User Experience (Loading & Errors).

Let’s dive in! 🚀

1. The Naive Approach (Don’t do this in production)

Here is how most beginners start:

import { useState, useEffect } from "react";

const UserProfile = () => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users/1")
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, []);

  if (!user) return <div>...</div>;

  return <h1>{user.name}</h1>;
};

It works
 on your fast local WiFi. But what’s wrong?

  1. No Error Handling: If the API fails, the app crashes or shows nothing.
  2. Weak Loading State: Just returning <div>...</div> implies user is null, but maybe we are just waiting?

2. The Robust Approach: 3 States

To build a bulletproof component, we need to track three things:

  1. Data: The result (if successful).
  2. Loading: Is the request still happening?
  3. Error: Did something go wrong?

Let’s rewrite it.

import { useState, useEffect } from "react";

const UserProfile = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true); // Start loading!
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users/1");

        if (!response.ok) {
          throw new Error("Network response was not ok");
        }

        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        setError(err.message);
        setData(null);
      } finally {
        setLoading(false); // Whether success or fail, stop loading
      }
    };

    fetchData();
  }, []);

  if (loading) return <div>Loading... ⏳</div>;
  if (error) return <div>Error: {error} ⚠</div>;

  return (
    <div className="card">
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
};

See the difference? Now your user knows exactly what’s happening.

This is what differentiates a Junior from a Senior. Handling the “Unhappy Paths”.


3. Level Up: Custom Hook useFetch

Okay, that code above is great. But imagine copying those 3 states (data, loading, error) into every single component that needs data. That’s messy. And repetitive.

Remember our rule? DRY (Don’t Repeat Yourself).

Let’s extract this logic into a Custom Hook called useFetch.

// hooks/useFetch.js
import { useState, useEffect } from "react";

export const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      try {
        const res = await fetch(url);
        if (!res.ok) throw new Error(res.statusText);
        const json = await res.json();
        setData(json);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]); // Re-run if URL changes

  return { data, loading, error };
};

Now look how clean our component becomes:

import { useFetch } from "./hooks/useFetch";

const UserProfile = () => {
  const { data, loading, error } = useFetch(
    "https://jsonplaceholder.typicode.com/users/1"
  );

  if (loading) return <div>Loading... ⏳</div>;
  if (error) return <div>Error: {error} ⚠</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
};

Boom! đŸ’„ One line of code to handle fetching, loading, and error states. You can reuse this useFetch hook in 100 different components.


Closing

Fetching data seems simple, but handling the user experience around it is an art.

  1. Always handle Loading states. Don’t leave the user guessing.
  2. Always catch Errors. APIs fail. Be ready.
  3. Extract logic. If you do it twice, make a Hook.

Next time you need data, don’t just fetch. useFetch.

Happy Coding! đŸ’»â˜•