DEV Community

Cover image for React Loading Screens Are a State Machine Problem
Mark Yu
Mark Yu

Posted on • Edited on

React Loading Screens Are a State Machine Problem

A loading screen is not a spinner problem.

It is a state problem.

The UI needs to know whether the request is idle, loading, successful, empty, or failed. If you only track loading: true/false, your component will eventually lie to the user.

The State Shape I Prefer

const [state, setState] = useState({
  status: "idle",
  data: null,
  error: null,
});
Enter fullscreen mode Exit fullscreen mode

The useful statuses:

idle -> loading -> success
               \-> empty
               \-> error
Enter fullscreen mode Exit fullscreen mode

This is already better than done.

A Practical Fetch Component

import { useEffect, useState } from "react";

export default function Posts() {
  const [state, setState] = useState({
    status: "idle",
    data: [],
    error: null,
  });

  useEffect(() => {
    let ignore = false;

    async function loadPosts() {
      setState({ status: "loading", data: [], error: null });

      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/posts");
        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data = await res.json();
        if (ignore) return;

        setState({
          status: data.length ? "success" : "empty",
          data,
          error: null,
        });
      } catch (error) {
        if (!ignore) {
          setState({ status: "error", data: [], error });
        }
      }
    }

    loadPosts();

    return () => {
      ignore = true;
    };
  }, []);

  if (state.status === "loading") return <LoadingScreen />;
  if (state.status === "error") return <ErrorMessage error={state.error} />;
  if (state.status === "empty") return <p>No posts yet.</p>;

  return (
    <ul>
      {state.data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

The ignore flag avoids setting state after the component unmounts. It is a small detail, but it prevents a class of annoying bugs.

CSS Spinner Without a Library

function LoadingScreen() {
  return (
    <div className="loader" role="status" aria-live="polite">
      <span className="spinner" />
      <p>Loading posts...</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
.loader {
  min-height: 180px;
  display: grid;
  place-items: center;
  gap: 12px;
}

.spinner {
  width: 36px;
  height: 36px;
  border: 4px solid #e5e7eb;
  border-top-color: #2563eb;
  border-radius: 50%;
  animation: spin 800ms linear infinite;
}

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

@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

I prefer this over adding a spinner dependency for a simple app.

When Lottie Makes Sense

Lottie is useful when loading is part of the product experience: onboarding, file upload, payment confirmation, or a long-running AI task.

I would not use it for every API fetch.

If the request usually takes 200ms, a dramatic animation feels slower, not better.

My Rule for Loading UI

Load time UI
< 300ms avoid spinner if possible
300ms - 2s small inline loader
2s - 10s skeleton or progress context
> 10s progress, cancellation, or background job

For AI-agent and long-running 2026 workflows, the last row matters a lot. Users need to know whether the system is still thinking, stuck, or waiting on a tool.

Final Thought

A good loading screen tells the truth.

It should not just spin. It should represent the real state of the request and give the user enough confidence to wait or recover.

How do you handle long-running loading states in your React apps?

Top comments (0)