Part 2.4 — UX State: Loading, Error, and Empty States in React
Every real UI is built around four major UX states:
Loading — the request is in progress
Error — the request failed
Empty — the request succeeded but there's no data
Success — data is available and ready to render
Most beginners only build the "success" case. But users experience the other three far more often — especially when a network or server is involved.
In this post, you’ll learn how to create clear, predictable UX states that make your app feel reliable, even under stress.
1. The Golden Rule of UX State
A frontend should never leave the user wondering:
“Is it doing something?”
“Did it break?”
“Was my action saved?”
“Why is nothing showing?”
Your UI must answer these questions instantly and consistently.
Let’s build that clarity step by step.
2. Start With a Typed Request State
Instead of juggling multiple booleans, define a simple union:
type RequestState = 'idle' | 'loading' | 'error' | 'success';Your component becomes easier to reason about:
const [state, setState] = useState<RequestState>('idle');
const [error, setError] = useState<string | null>(null);This keeps your logic predictable as your app grows.
3. A Simple Data Loader With Clear UX States
Example: loading posts from /api/posts.
import { useEffect, useState } from 'react';
import { http } from '../lib/http';
import type { Post } from '../types/post';
export function PostsLoader() {
const [posts, setPosts] = useState<Post[]>([]);
const [state, setState] = useState<RequestState>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
http<Post[]>('/api/posts')
.then(data => {
setPosts(data);
setState('success');
})
.catch(err => {
setError(String(err));
setState('error');
});
}, []);Now render each UX state:
if (state === 'loading') return <p>Loading posts...</p>;
if (state === 'error') return <p className="text-red-500">{error}</p>;
if (state === 'success' && posts.length === 0) return <p>No posts yet.</p>;This is already more professional than most production apps.
4. Use Skeleton Screens (Better Than Spinners)
A spinner shows activity but hides structure.
Skeletons show users what is being loaded, reducing perceived wait time.
function PostSkeleton() {
return (
<div className="animate-pulse p-4 border rounded space-y-2">
<div className="h-4 bg-gray-300 rounded w-1/3" />
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
</div>
);
}Use it:
if (state === 'loading') {
return (
<div className="space-y-4 mt-4">
<PostSkeleton />
<PostSkeleton />
<PostSkeleton />
</div>
);
}Your UI transitions feel natural instead of jarring.
5. Error States That Actually Help Users
A good error message includes:
What went wrong
What the user can do
A retry button
Example:
function ErrorMessage({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="text-red-500 space-y-2">
<p>{message}</p>
<button
className="px-3 py-1 bg-red-600 text-white rounded"
onClick={onRetry}
>
Try again
</button>
</div>
);
}Integrate:
if (state === 'error') {
return <ErrorMessage message={error!} onRetry={() => window.location.reload()} />;
}Later, we’ll replace the reload with a refined retry mechanism.
6. Clean "Empty" State That Encourages Action
Never show a blank screen — guide the user.
if (posts.length === 0) {
return (
<div className="text-gray-500 mt-4">
<p>No posts yet.</p>
<p className="text-sm">Create your first post above.</p>
</div>
);
}A micro-guidance message drives engagement.
7. Show “Optimistic UI” for Faster Perceived Updates
When adding or deleting items, update the UI before the server responds.
Example (creating a new post):
const optimistic = { id: Date.now(), title, body };
setPosts(prev => [...prev, optimistic]);
try {
const saved = await createPost({ title, body });
setPosts(prev => prev.map(p => p.id === optimistic.id ? saved : p));
} catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimistic.id));
setError('Failed to save post.');
}This creates a snappy, modern feel — like Twitter or Notion.
We’ll revisit optimistic updates later when integrating your NestJS backend.
8. Extract UX Patterns Into Reusable Components
Good teams don’t repeat UX markup.
Create reusable building blocks:
/src/components/ux/
├── LoadingSkeleton.tsx
├── ErrorMessage.tsx
├── EmptyState.tsx
├── RetryButton.tsx
└── AsyncBoundary.tsx (future: suspense-friendly)This makes your app consistent and easy to maintain.
9. Combine All States Into a Polished Page
export function PostsPage() {
const { posts, state, error, load } = useBetterPosts(); // a custom hook
if (state === 'loading') return <PostsSkeletonList />;
if (state === 'error') return <ErrorMessage message={error!} onRetry={load} />;
if (posts.length === 0) return <EmptyState message="No posts yet." />;
return <PostsList posts={posts} />;
}This is how real production apps handle data.
10. Why This Matters When the Backend Arrives
Once your NestJS REST API is online, UX states will occur constantly:
Cold-start backend → slow fetch → skeleton
Database unavailable → backend error → error UI
Empty table → empty state
CRUD operations → optimistic UI
Because your frontend handles each case gracefully, users never feel the backend’s rough edges.
11. Summary
You now know how to design real UX flows with:
Typed loading states
Error screens with retry
Skeleton placeholders
Empty-state guidance
Success state rendering
Optimistic updates
In Part 2.5, you’ll tie everything together with shared types, error-handling strategies, and safe integration patterns that prepare your React app for the NestJS backend you’ll build in Part 3.
Related
Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies
Real-world frontends need safety: typed API responses, predictable errors, and consistent client logic. This post teaches you the patterns professionals use to integrate React with REST cleanly.
Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints
Forms connect users to data. In this post, you’ll learn how to validate input, manage UX states, and submit cleanly typed data to real REST endpoints.
Part 2.2 — Building CRUD Interfaces: Posts, Users, and API Contracts
CRUD is the backbone of nearly every full-stack app. In this post, you’ll build clean React CRUD interfaces that match real REST API contracts you’ll implement in NestJS and Prisma later.
Comments