Loading State
Problem
A user clicks “Save” and nothing visible happens. The button looks the same, the form looks the same, and 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 repeatedly because nothing indicated the app was working. Some refresh pages mid-save assuming the app froze, when it’s actually working fine in the background. Others navigate away and lose their work because there’s no indication they should wait.
The flip side is equally jarring: content that appears instantly without transition causes layout shifts that make users lose their place on the page.
Solution
Show users what’s happening at all times. Too many apps forget this basic UX principle.
When something is loading, display a spinner, skeleton screen, or progress bar. When a button triggers an async operation, disable it and show “Saving…” so users know their click registered. When fetching content, show placeholders that preserve layout so the page doesn’t jump when data arrives.
Skeleton screens work particularly well because they show the shape of incoming content, which feels faster than a blank spinner even when wait times are identical.
For long operations, show actual progress rather than an indeterminate spinner—“Uploading: 47%” tells users how long to wait, while an endless spinner could be stuck forever. Track loading state separately from data and error states so you can distinguish between “loading,” “loaded but empty,” and “failed.”
Example
Here’s loading state in action, from basic spinners to skeleton screens to progress bars for uploads.
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);
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, onMounted } from 'vue';
const props = defineProps(['userId']);
const loading = ref(true);
const error = ref(null);
const user = ref(null);
onMounted(async () => {
try {
user.value = await fetchUser(props.userId);
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
});
</script> <script>
export let userId;
let userPromise = fetchUser(userId);
</script>
{#await userPromise}
<div class="spinner">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">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 shape of 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-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 {
setOptimisticTodos(todos);
} 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 => {
clearTimeout(timer);
setShowSpinner(false);
setData(result);
});
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 handleSearch = async (q) => {
setSearching(true);
const data = await search(q);
setResults(data);
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—users won’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 wait times are identical.
- You eliminate ambiguity: users can distinguish between “loading,” “loaded but empty,” and “failed.”
- Duplicate submissions disappear 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 add a short delay before showing loading UI.
- You need more state in every component:
loading,data, anderrorinstead of just data. - Skeleton screens require maintenance—when content layout changes, skeletons must update to match.
- Optimistic updates are tricky because you need robust rollback logic when operations fail after showing success.
- Progress indicators require backend support; if your API doesn’t report progress events, you can’t show meaningful percentages.
- Don’t let polished loading states mask performance problems—the real fix is making operations faster, not adding better spinners.
- Accessibility is easy to overlook. ARIA attributes like
role="status"andaria-busyare often forgotten.
Summary
Loading states communicate that your application is working, preventing user confusion and duplicate actions during asynchronous operations. Show spinners on buttons, skeleton screens for content, and progress bars for uploads. The key is matching the feedback to the operation: brief operations might need delayed indicators to avoid flicker, while long operations need progress information to set expectations.