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>;
}
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.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]).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} /> |