Loading State
Show loading indicators while asynchronous operations are in progress.
In this article
Problem
A user clicks “Save” and nothing visible happens. The button and the form look the same, so they have no way to know if their action registered. So they click again and accidentally submit the form twice.
I’ve watched users rage-click buttons because nothing indicated the app was working. Others refresh or navigate away mid-save, assuming the app has locked up when it’s still working in the background, and lose their work.
Content that pops in without any transition causes the opposite problem: layout shifts that make users lose their place on the page.
Solution
Users should be able to see what’s happening at all times, and a lot of apps leave this out.
When something is loading, a visible indicator shows it. You can disable a button that triggers an async operation and show “Saving…” so users know their click registered, and you can give content that’s still being fetched placeholders that preserve layout, so the page doesn’t jump when data arrives.
Skeleton screens work particularly well because they show the layout of the incoming content, which feels faster than a blank spinner even when the load takes the same amount of time.
For long operations, real progress is more useful than an indeterminate spinner. “Uploading: 47%” tells users how long to wait, while an endless spinner gives no sign of whether it’s stuck. Tracking loading state separately from data and error states lets you distinguish between “loading,” “loaded but empty,” and “failed.”
Example
The examples below start with a basic spinner and work up to more specialized indicators.
Basic Loading State
function UserProfile({ userId }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function loadUser() {
setLoading(true);
setError(null);
try {
const data = await fetchUser(userId);
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err);
} finally {
if (!cancelled) setLoading(false);
}
}
loadUser();
return () => { cancelled = true; };
}, [userId]);
if (loading) return <div className="spinner" role="status">Loading...</div>;
if (error) return <div className="error">Failed to load user</div>;
return <div>{user.name}</div>;
} <template>
<div v-if="loading" class="spinner" role="status">Loading...</div>
<div v-else-if="error" class="error">{{ error.message }}</div>
<div v-else>{{ user.name }}</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps(['userId']);
const loading = ref(true);
const error = ref(null);
const user = ref(null);
watch(() => props.userId, async (userId, oldUserId, onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; });
loading.value = true;
error.value = null;
try {
const data = await fetchUser(userId);
if (!cancelled) user.value = data;
} catch (err) {
if (!cancelled) error.value = err;
} finally {
if (!cancelled) loading.value = false;
}
}, { immediate: true });
</script> <script>
let { userId } = $props();
let userPromise = $derived(fetchUser(userId));
</script>
{#await userPromise}
<div class="spinner" role="status">Loading...</div>
{:then user}
<div>{user.name}</div>
{:catch error}
<div class="error">Failed to load user</div>
{/await} class UserProfile extends HTMLElement {
loading = true;
user = null;
error = null;
async connectedCallback() {
this.render();
try {
this.user = await fetchUser(this.getAttribute('user-id'));
} catch (err) {
this.error = err;
} finally {
this.loading = false;
this.render();
}
}
render() {
if (this.loading) this.innerHTML = '<div class="spinner" role="status">Loading...</div>';
else if (this.error) this.innerHTML = '<div class="error">Failed</div>';
else this.innerHTML = `<div>${this.user.name}</div>`;
}
}
customElements.define('user-profile', UserProfile); Button Loading State
Disabling buttons and showing feedback prevents double-clicks and confirms the action was received:
function SaveButton({ onSave }) {
const [saving, setSaving] = useState(false);
const handleClick = async () => {
setSaving(true);
try {
await onSave();
} finally {
setSaving(false);
}
};
return (
<button
onClick={handleClick}
disabled={saving}
aria-busy={saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
);
}
Skeleton Screen
Skeleton placeholders preserve layout and feel faster than blank spinners by hinting at the layout of the incoming content:
function UserList({ users, loading }) {
if (loading) {
return (
<div className="user-list">
{[1, 2, 3].map(i => (
<div key={i} className="user-skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-text" />
</div>
))}
</div>
);
}
return (
<div className="user-list">
{users.map(user => <UserCard key={user.id} user={user} />)}
</div>
);
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
Progress Bar
For long-running operations like file uploads, showing actual progress helps users understand how long to wait:
function FileUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleUpload = async (file) => {
setUploading(true);
setProgress(0);
try {
await uploadFile(file, (progressEvent) => {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setProgress(percent);
});
} finally {
setUploading(false);
}
};
return (
<div>
<input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
{uploading && (
<div
className="progress-bar"
role="progressbar"
aria-label="Upload progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div className="progress-fill" style={{ width: `${progress}%` }} />
<span>{progress}%</span>
</div>
)}
</div>
);
}
Optimistic Updates
Updating the UI immediately and rolling back on failure makes interactions feel instant:
function TodoList({ todos }) {
const [optimisticTodos, setOptimisticTodos] = useState(todos);
const [loading, setLoading] = useState({});
const handleToggle = async (id) => {
// Update UI immediately, revert on failure
setOptimisticTodos(prev =>
prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
setLoading(prev => ({ ...prev, [id]: true }));
try {
await toggleTodo(id);
} catch {
// Flip just this todo back, leaving any other pending toggles alone
setOptimisticTodos(prev =>
prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
} finally {
setLoading(prev => ({ ...prev, [id]: false }));
}
};
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
disabled={loading[todo.id]}
/>
{todo.text}
</li>
))}
</ul>
);
}
Delayed Loading Indicator
For fast operations, delaying the spinner prevents annoying flicker when content loads quickly:
function DataDisplay() {
const [showSpinner, setShowSpinner] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
// Only show spinner if loading takes longer than 200ms
const timer = setTimeout(() => setShowSpinner(true), 200);
fetchData()
.then(result => setData(result))
.finally(() => {
clearTimeout(timer);
setShowSpinner(false);
});
return () => clearTimeout(timer);
}, []);
if (showSpinner) return <Spinner />;
return data ? <Content data={data} /> : null;
}
Inline Loading State
A small spinner next to the input keeps feedback close to the action:
function SearchBox() {
const [query, setQuery] = useState('');
const [searching, setSearching] = useState(false);
const [results, setResults] = useState([]);
const controllerRef = useRef(null);
const handleSearch = async (q) => {
// Cancel the previous request so an older response can't overwrite newer results
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setSearching(true);
try {
const data = await search(q, { signal: controller.signal });
if (!controller.signal.aborted) setResults(data);
} catch (err) {
if (err.name !== 'AbortError') throw err;
} finally {
if (controllerRef.current === controller) setSearching(false);
}
};
return (
<div>
<div className="search-input-wrapper">
<input
type="search"
value={query}
onChange={(e) => { setQuery(e.target.value); handleSearch(e.target.value); }}
/>
{searching && <span className="spinner-inline" />}
</div>
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}
Benefits
- Users stop rage-clicking because visual feedback confirms their action registered and they should wait.
- Page abandonment drops, because users don’t refresh or navigate away when they can see the app is working.
- Perceived performance improves because skeleton screens feel faster than blank spinners, even when the load takes the same amount of time.
- Users can tell the difference between “loading,” “loaded but empty,” and “failed.”
- Duplicate submissions stop because disabled buttons prevent accidental double-clicks.
- Progress bars set expectations: “Uploading: 47%” tells users how long to wait and confirms progress is being made.
- Layout stays stable because skeletons reserve space, preventing jarring shifts when content arrives.
Tradeoffs
- Spinner spam is a real risk. If every operation shows its own indicator, your UI becomes a mess of competing spinners.
- Fast operations can flash indicators annoyingly; a 50ms spinner is worse than none, so you should add a short delay before showing loading UI.
- You need more state in every component:
loading,data, anderrorinstead of just data. - Skeleton screens need maintenance: when the content layout changes, you have to update the skeletons to match.
- Optimistic updates are tricky because you need rollback logic for operations that fail after the UI has already shown success.
- Progress indicators require backend support; if your API doesn’t report progress events, you can’t show meaningful percentages.
- Polished loading states can hide performance problems, and a better spinner doesn’t make the operation itself any faster.
- Accessibility is easy to overlook, and you might leave out ARIA attributes like
role="status"andaria-busywithout noticing.
Summary
Loading states tell users your application is working, which prevents confusion and duplicate actions during asynchronous operations. Spinners suit buttons and skeleton screens suit content. The feedback should match the operation: brief operations might need delayed indicators to avoid flicker, while long operations need progress information to set expectations.
See also
- Error Handling — Resilience
- Async Boundary — Resilience
- Progress Indicators - NN Group
- Suspense - React
- Suspense - Vue
- Await Blocks - Svelte