Users hate waiting without feedback. Loading states and skeleton loaders improve UX by showing that something is happening.
A loading state is the application state while data is being loaded (e.g., from an API).
1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 fetchPosts()
7 .then(data => setPosts(data))
8 .finally(() => setLoading(false));
9 }, []);
10
11 if (loading) {
12 return <div className="text-center p-8">Loading...</div>;
13 }
14
15 return (
16 <div>
17 {posts.map(post => <PostCard key={post.id} post={post} />)}
18 </div>
19 );
20}Problem: "Loading..." is boring. Users don't know how long they need to wait.
A skeleton is a placeholder that mimics the layout of the content being loaded.
1function PostSkeleton() {
2 return (
3 <div className="border rounded-lg p-6 bg-white animate-pulse">
4 {/* Header */}
5 <div className="flex items-center gap-3 mb-4">
6 <div className="w-12 h-12 bg-gray-300 rounded-full" />
7 <div className="flex-1 space-y-2">
8 <div className="h-4 bg-gray-300 rounded w-32" />
9 <div className="h-3 bg-gray-200 rounded w-24" />
10 </div>
11 </div>
12
13 {/* Content */}
14 <div className="space-y-2 mb-4">
15 <div className="h-4 bg-gray-300 rounded" />
16 <div className="h-4 bg-gray-300 rounded w-5/6" />
17 </div>
18
19 {/* Image */}
20 <div className="h-64 bg-gray-300 rounded-lg mb-4" />
21
22 {/* Actions */}
23 <div className="flex gap-4">
24 <div className="h-8 w-16 bg-gray-300 rounded" />
25 <div className="h-8 w-16 bg-gray-300 rounded" />
26 </div>
27 </div>
28 );
29}animate-pulse creates a "pulsing" animation - perfect for skeletons!1<div className="animate-pulse bg-gray-300 h-10 rounded" />1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 fetchPosts()
7 .then(data => setPosts(data))
8 .finally(() => setLoading(false));
9 }, []);
10
11 if (loading) {
12 return (
13 <div className="space-y-4">
14 <PostSkeleton />
15 <PostSkeleton />
16 <PostSkeleton />
17 </div>
18 );
19 }
20
21 return (
22 <div className="space-y-4">
23 {posts.map(post => <PostCard key={post.id} post={post} />)}
24 </div>
25 );
26}What if the API returns an error?
1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState<string | null>(null);
5
6 useEffect(() => {
7 fetchPosts()
8 .then(data => setPosts(data))
9 .catch(err => setError(err.message))
10 .finally(() => setLoading(false));
11 }, []);
12
13 if (loading) {
14 return <div className="space-y-4">
15 {[...Array(3)].map((_, i) => <PostSkeleton key={i} />)}
16 </div>;
17 }
18
19 if (error) {
20 return (
21 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
22 <p className="font-bold">Error loading posts</p>
23 <p>{error}</p>
24 <button
25 onClick={() => window.location.reload()}
26 className="mt-2 underline"
27 >
28 Try again
29 </button>
30 </div>
31 );
32 }
33
34 return (
35 <div className="space-y-4">
36 {posts.map(post => <PostCard key={post.id} post={post} />)}
37 </div>
38 );
39}What if the API returns an empty array?
1if (posts.length === 0) {
2 return (
3 <div className="text-center p-12">
4 <div className="text-6xl mb-4">🏴☠️</div>
5 <h2 className="text-2xl font-bold mb-2">No posts yet</h2>
6 <p className="text-gray-600 mb-4">
7 Be the first pirate to post something!
8 </p>
9 <button className="bg-blue-600 text-white px-6 py-2 rounded">
10 Create Post
11 </button>
12 </div>
13 );
14}Buttons should also show loading state:
1function SubmitButton() {
2 const [loading, setLoading] = useState(false);
3
4 const handleClick = async () => {
5 setLoading(true);
6 await submitForm();
7 setLoading(false);
8 };
9
10 return (
11 <button
12 onClick={handleClick}
13 disabled={loading}
14 className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
15 >
16 {loading && (
17 <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
18 <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
19 <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
20 </svg>
21 )}
22 {loading ? 'Submitting...' : 'Submit'}
23 </button>
24 );
25}React 18 has Suspense for handling loading:
1import { Suspense } from 'react';
2
3function App() {
4 return (
5 <Suspense fallback={<PostSkeleton />}>
6 <PirateFeed />
7 </Suspense>
8 );
9}Suspense automatically catches loading if you use:
This is advanced - for now use useState!
✅ Loading state -
useState(true) + setLoading(false)
✅ Skeleton loaders - placeholder UI mimicking content layout
✅ Error state - catch errors, show message
✅ Empty state - when no data, show friendly UI
✅ Button loading - disabled + spinner
✅ Suspense - React 18 approach (advanced)UX Rule: Always give feedback to the user - loading, error, success!
See you there! 🚀