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,
});
The useful statuses:
idle -> loading -> success
\-> empty
\-> error
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>
);
}
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>
);
}
.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;
}
}
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)