Handling Loading, Errors and Empty States in the Connected UI

A connected React component always has at least three states: it is loading data, it has successfully loaded data, or it encountered an error. Handling all three consistently โ€” with the right visual feedback, actionable error messages, and graceful empty states โ€” is what makes the difference between an application that feels professional and one that feels broken. In this lesson you will build robust state handling patterns for every connected component in the MERN Blog, including optimistic UI updates that make delete and like operations feel instant without sacrificing correctness.

The Three API States โ€” Pattern

// Standard three-state pattern for every connected component
function ConnectedComponent() {
  const [data,    setData]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    const load = async () => {
      try {
        setLoading(true);
        setError(null);
        const res = await someService.getAll();
        setData(res.data.data);
      } catch (err) {
        setError(err.response?.data?.message || 'Something went wrong');
      } finally {
        setLoading(false);
      }
    };
    load();
  }, []);

  // Guard clauses โ€” handle each state before the main render
  if (loading) return <Spinner message="Loading..." />;
  if (error)   return <ErrorMessage message={error} onRetry={() => setError(null) || load()} />;
  if (!data || data.length === 0) return <EmptyState />;

  // Only reaches here when data is loaded and non-empty
  return <div>{data.map(item => ...)}</div>;
}
Note: The order of guard clauses matters. Always check loading first โ€” if data is loading, nothing else matters. Then check error โ€” if there was an error, show it even if data is partially set. Then check empty data. Only after all guards pass do you render the main content. This “fail-fast” pattern prevents rendering broken partial states.
Tip: Build a useAsyncState custom hook that encapsulates the three-state pattern and the useEffect fetch call. Pass it a service function and parameters, and it returns { data, loading, error, refetch }. Every list and detail page in the MERN Blog can then be a clean one-liner: const { data: posts, loading, error } = useAsyncState(() => postService.getAll({ page }), [page]).
Warning: Be careful with the retry button on error states. If the error was caused by an invalid ID in the URL (a 400 or 404), retrying will produce the same error. Only show a retry button for transient errors (network failures, 503 server errors). For 404 errors, show a “Go back” link instead.

A Reusable useAsyncState Hook

// src/hooks/useAsyncState.js
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';

function useAsyncState(asyncFn, deps = []) {
  const [data,    setData]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);
  const isMounted = useRef(true);

  const execute = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const result = await asyncFn();
      if (isMounted.current) setData(result.data);
    } catch (err) {
      if (isMounted.current && !axios.isCancel(err)) {
        setError(err.response?.data?.message || err.message || 'Request failed');
      }
    } finally {
      if (isMounted.current) setLoading(false);
    }
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    isMounted.current = true;
    execute();
    return () => { isMounted.current = false; };
  }, [execute]);

  return { data, loading, error, refetch: execute };
}

export default useAsyncState;

// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function PostListPage() {
  const [page, setPage] = useState(1);
  const { data, loading, error, refetch } = useAsyncState(
    () => postService.getAll({ page, limit: 10, published: true }),
    [page]
  );

  if (loading) return <Spinner />;
  if (error)   return <ErrorMessage message={error} onRetry={refetch} />;
  return (
    <div>
      <PostList posts={data?.data || []} />
      <Pagination page={page} total={data?.total} onPageChange={setPage} />
    </div>
  );
}

Optimistic UI Updates

// Optimistic update: update UI immediately, roll back on error

function PostList({ posts: initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);

  // โ”€โ”€ Optimistic delete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  const handleDelete = async (postId) => {
    if (!window.confirm('Delete this post?')) return;

    // 1. Update UI immediately โ€” remove from list
    const previous = posts;
    setPosts(prev => prev.filter(p => p._id !== postId));

    try {
      // 2. Confirm with server
      await postService.remove(postId);
      // Server confirmed โ€” UI is already correct โœ“
    } catch (err) {
      // 3. Server failed โ€” roll back to previous state
      setPosts(previous);
      alert(err.response?.data?.message || 'Could not delete post. Please try again.');
    }
  };

  // โ”€โ”€ Optimistic like toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  const handleLike = async (postId) => {
    // 1. Toggle liked state immediately
    const previous = posts;
    setPosts(prev => prev.map(p =>
      p._id === postId
        ? { ...p, liked: !p.liked, likeCount: p.liked ? p.likeCount - 1 : p.likeCount + 1 }
        : p
    ));

    try {
      await postService.toggleLike(postId);
    } catch (err) {
      // Roll back on failure
      setPosts(previous);
    }
  };

  return (
    <div className="post-list">
      {posts.map(post => (
        <PostCard
          key={post._id}
          post={post}
          onDelete={handleDelete}
          onLike={handleLike}
        />
      ))}
    </div>
  );
}

The Empty State Component

// src/components/ui/EmptyState.jsx
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';

function EmptyState({
  icon       = '๐Ÿ“ญ',
  title      = 'Nothing here yet',
  message    = '',
  actionLabel,
  actionTo,
  onAction,
}) {
  return (
    <div className="empty-state">
      <span className="empty-state__icon">{icon}</span>
      <h3 className="empty-state__title">{title}</h3>
      {message && <p className="empty-state__message">{message}</p>}
      {actionLabel && (
        actionTo
          ? <Link to={actionTo} className="btn btn--primary">{actionLabel}</Link>
          : <button onClick={onAction} className="btn btn--primary">{actionLabel}</button>
      )}
    </div>
  );
}

EmptyState.propTypes = {
  icon: PropTypes.string, title: PropTypes.string,
  message: PropTypes.string, actionLabel: PropTypes.string,
  actionTo: PropTypes.string, onAction: PropTypes.func,
};

export default EmptyState;

// โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
{posts.length === 0 && (
  <EmptyState
    icon="โœ๏ธ"
    title="No posts yet"
    message="Be the first to share something with the community."
    actionLabel="Write a Post"
    actionTo="/posts/new"
  />
)}

Common Mistakes

Mistake 1 โ€” Showing the error from a previous attempt after retry

โŒ Wrong โ€” error state not cleared before retry:

const retry = () => fetchData(); // error state still set from previous attempt
// User sees old error flash briefly even if retry succeeds

โœ… Correct โ€” clear error at start of each fetch:

const retry = () => { setError(null); fetchData(); }; // โœ“

Mistake 2 โ€” Not rolling back optimistic updates on failure

โŒ Wrong โ€” UI shows deleted item as gone even though server rejected the delete:

setPosts(prev => prev.filter(p => p._id !== id));
await postService.remove(id); // if this throws โ€” post is gone from UI but still in DB!

โœ… Correct โ€” store previous state and restore on error:

const prev = posts;
setPosts(p => p.filter(p => p._id !== id));
try { await postService.remove(id); }
catch { setPosts(prev); } // โœ“ rollback

Mistake 3 โ€” Not showing a meaningful empty state

โŒ Wrong โ€” component renders nothing when data is an empty array:

return <div>{posts.map(p => <PostCard ... />)}</div>;
// When posts = [] โ†’ blank div โ€” user sees nothing, does not know why

โœ… Correct โ€” always handle the empty case explicitly:

if (posts.length === 0) return <EmptyState title="No posts found" .../>; // โœ“

Quick Reference

Pattern Code
Three state guards if loading โ†’ Spinner; if error โ†’ ErrorMessage; if empty โ†’ EmptyState
Clear error before retry setError(null); fetchData();
Optimistic update Save prev, update UI, try API, catch โ†’ restore prev
Empty state <EmptyState title="..." actionTo="/posts/new" />
Retry button <ErrorMessage message={error} onRetry={refetch} />

🧠 Test Yourself

You implement an optimistic delete โ€” removing the post from state immediately before the API call. The server returns a 403 because the user is not the owner. What must your code do and what should the user see?