Skip to main content

Why loading states matter

When your app fetches data, there’s a gap between clicking and seeing results. Without a loading state, users see either nothing (blank screen) or stale data. They don’t know if the app is working.
// ❌ No loading state — user sees blank page for 1-3 seconds
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/api/users")
      .then(r => r.json())
      .then(data => setUsers(data));
  }, []);

  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
  // Empty list until data arrives — looks broken
}

The loading state pattern

Every data-fetching component needs three states: loading, error, and data.
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadUsers() {
      try {
        setLoading(true);
        const response = await fetch("/api/users");
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    loadUsers();
  }, []);

  if (loading) return <p>Loading users...</p>;
  if (error) return <p>Error: {error}</p>;
  if (users.length === 0) return <p>No users found.</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
This is the pattern you’ll use in almost every component that fetches data. Three states, three checks at the top of the return.
Notice the order: check loading first, then error, then empty state, then render data. This order matters because you want to show the most relevant state.

Loading indicators

Simple text

if (loading) return <p>Loading...</p>;

Spinner component

function Spinner() {
  return <div className="spinner" aria-label="Loading" />;
}

// Usage
if (loading) return <Spinner />;
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

Inline loading (inside existing content)

function UserList() {
  // ...state setup

  return (
    <div>
      <h2>Users {loading && <Spinner />}</h2>
      {error && <p className="error">{error}</p>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}
Use full-page loading for initial loads and inline loading for refreshes. If the user already sees data, don’t replace it with a spinner — show the spinner alongside the existing content.

Disabling buttons during requests

Prevent users from double-clicking submit buttons:
function CreateUserForm() {
  const [name, setName] = useState("");
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);

    try {
      const response = await fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name }),
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const newUser = await response.json();
      console.log("Created:", newUser);
    } catch (error) {
      console.error("Failed:", error);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        disabled={submitting}
      />
      <button type="submit" disabled={submitting}>
        {submitting ? "Creating..." : "Create User"}
      </button>
    </form>
  );
}
Key details:
  • disabled={submitting} prevents the button from being clicked again
  • Button text changes to show progress (“Creating…”)
  • Input is also disabled to prevent editing during submission
  • finally ensures the button re-enables even if the request fails
Always disable submit buttons during requests. Without this, users can click multiple times and create duplicate entries. This is one of the most common bugs in web applications.

The complete data fetching pattern

This is the pattern you’ll reuse across your entire application:
function DataComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch("/api/endpoint");
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchData();
  }, []);

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!data) return <EmptyState />;

  return <DataDisplay data={data} />;
}
This three-state pattern (loading / error / data) shows up so often that libraries like React Query and SWR were created to automate it. For now, writing it manually teaches you what’s happening under the hood.

Refactoring into a custom hook

Once you’re comfortable with the pattern, extract it into a reusable hook:
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    fetchData();
  }, [url]);

  return { data, loading, error };
}

// Usage — so clean!
function UserList() {
  const { data: users, loading, error } = useFetch("/api/users");

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
One hook, any endpoint. The same loading/error/data pattern without repeating yourself.

What’s next?

You’ve covered the complete async & APIs section — from understanding async JavaScript to making real API calls with proper error handling and loading states. Next up: the DOM and browser APIs. You’ll learn how JavaScript interacts with the page itself — selecting elements, modifying content, and responding to user actions.

What is the DOM?

Understand how JavaScript sees and interacts with your page