Skip to content

Part 2.4 — UX State: Loading, Error, and Empty States in React

Site Console Site Console
5 min read Updated Nov 28, 2025 Web Development 0 comments
Managing Loading, Error, and Empty States in React

Every real UI is built around four major UX states:

  1. Loading — the request is in progress

  2. Error — the request failed

  3. Empty — the request succeeded but there's no data

  4. 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

Leave a comment

Sign in to leave a comment.

Comments