Introduction
In React, async operations like API calls do not block rendering. To show a loading spinner while waiting for data, you use a state variable (typically loading) that starts as true, triggers the spinner, and is set to false when the async operation completes. The standard pattern is to call the async function inside useEffect, update state with useState, and conditionally render a spinner or the data based on the loading state. This pattern applies to any async operation — API fetches, file reads, or computations.
Basic Loading Spinner Pattern
1import { useState, useEffect } from 'react';
2
3function UserProfile({ userId }) {
4 const [user, setUser] = useState(null);
5 const [loading, setLoading] = useState(true);
6 const [error, setError] = useState(null);
7
8 useEffect(() => {
9 async function fetchUser() {
10 try {
11 setLoading(true);
12 const response = await fetch(`/api/users/${userId}`);
13 if (!response.ok) throw new Error('Failed to fetch');
14 const data = await response.json();
15 setUser(data);
16 } catch (err) {
17 setError(err.message);
18 } finally {
19 setLoading(false);
20 }
21 }
22
23 fetchUser();
24 }, [userId]);
25
26 if (loading) return <div className="spinner" />;
27 if (error) return <div>Error: {error}</div>;
28 return <div>{user.name}</div>;
29}
The loading state controls which UI is rendered. The finally block ensures setLoading(false) runs whether the fetch succeeds or fails.
Spinner Component
1function Spinner({ size = 40, color = '#3b82f6' }) {
2 return (
3 <div
4 style={{
5 width: size,
6 height: size,
7 border: `4px solid #e5e7eb`,
8 borderTop: `4px solid ${color}`,
9 borderRadius: '50%',
10 animation: 'spin 0.8s linear infinite',
11 }}
12 />
13 );
14}
15
16// Add CSS animation
17// @keyframes spin { to { transform: rotate(360deg); } }
18
19function DataList() {
20 const [items, setItems] = useState([]);
21 const [loading, setLoading] = useState(true);
22
23 useEffect(() => {
24 fetch('/api/items')
25 .then(res => res.json())
26 .then(data => setItems(data))
27 .finally(() => setLoading(false));
28 }, []);
29
30 return (
31 <div>
32 <h2>Items</h2>
33 {loading ? (
34 <Spinner size={32} />
35 ) : (
36 <ul>
37 {items.map(item => <li key={item.id}>{item.name}</li>)}
38 </ul>
39 )}
40 </div>
41 );
42}
Custom Hook for Loading State
1function useAsync(asyncFn, deps = []) {
2 const [data, setData] = useState(null);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 let cancelled = false;
8
9 setLoading(true);
10 setError(null);
11
12 asyncFn()
13 .then(result => {
14 if (!cancelled) setData(result);
15 })
16 .catch(err => {
17 if (!cancelled) setError(err);
18 })
19 .finally(() => {
20 if (!cancelled) setLoading(false);
21 });
22
23 return () => { cancelled = true; };
24 }, deps);
25
26 return { data, loading, error };
27}
28
29// Usage
30function ProductPage({ productId }) {
31 const { data: product, loading, error } = useAsync(
32 () => fetch(`/api/products/${productId}`).then(r => r.json()),
33 [productId]
34 );
35
36 if (loading) return <Spinner />;
37 if (error) return <div>Error: {error.message}</div>;
38 return <div>{product.name} - ${product.price}</div>;
39}
The custom hook encapsulates the loading/error/data pattern and includes a cleanup flag to prevent state updates on unmounted components.
Multiple Async Operations
1function Dashboard() {
2 const [loading, setLoading] = useState(true);
3 const [data, setData] = useState({});
4
5 useEffect(() => {
6 async function loadDashboard() {
7 try {
8 const [users, orders, stats] = await Promise.all([
9 fetch('/api/users').then(r => r.json()),
10 fetch('/api/orders').then(r => r.json()),
11 fetch('/api/stats').then(r => r.json()),
12 ]);
13 setData({ users, orders, stats });
14 } catch (err) {
15 console.error('Dashboard load failed:', err);
16 } finally {
17 setLoading(false);
18 }
19 }
20
21 loadDashboard();
22 }, []);
23
24 if (loading) return <Spinner />;
25
26 return (
27 <div>
28 <UserList users={data.users} />
29 <OrderTable orders={data.orders} />
30 <StatsPanel stats={data.stats} />
31 </div>
32 );
33}
Promise.all fetches all data in parallel. The spinner shows until every request completes.
Skeleton Loading Pattern
1function UserCard({ userId }) {
2 const [user, setUser] = useState(null);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 fetch(`/api/users/${userId}`)
7 .then(r => r.json())
8 .then(setUser)
9 .finally(() => setLoading(false));
10 }, [userId]);
11
12 if (loading) {
13 return (
14 <div className="user-card">
15 <div className="skeleton skeleton-avatar" />
16 <div className="skeleton skeleton-text" />
17 <div className="skeleton skeleton-text short" />
18 </div>
19 );
20 }
21
22 return (
23 <div className="user-card">
24 <img src={user.avatar} alt={user.name} />
25 <h3>{user.name}</h3>
26 <p>{user.email}</p>
27 </div>
28 );
29}
Skeleton screens show placeholder shapes that match the final layout, giving users a sense of structure before data loads. This feels faster than a generic spinner.
Common Pitfalls
Defining async functions directly in useEffect: useEffect(async () => { ... }) returns a promise, but React expects useEffect to return a cleanup function or nothing. Define the async function inside and call it: useEffect(() => { async function load() { ... } load(); }, []).
Not handling component unmount: If the component unmounts before the fetch completes, calling setState triggers a warning. Use a cancelled flag in the cleanup function: return () => { cancelled = true; } and check it before calling setState.
Forgetting the loading state on re-fetch: When dependencies change (e.g., userId), the component re-fetches data. If you do not reset loading to true at the start, stale data shows briefly before the new data loads. Always setLoading(true) before starting a new fetch.
Not showing errors alongside loading: If a fetch fails, loading becomes false but data remains null. Without an error state, the component renders with no data and no explanation. Always track errors alongside loading state.
Blocking render with synchronous operations: Heavy computations in the render path freeze the UI even with a spinner. Move expensive work into useEffect or a Web Worker so the spinner can actually animate while the work happens in the background.
Summary
Use useState for loading, data, and error to track async operation state
Call async functions inside useEffect, not directly as the effect callback
Render a spinner or skeleton when loading is true, the actual content when false
Use Promise.all for parallel fetches with a single loading indicator
Clean up with a cancelled flag to prevent state updates on unmounted components