Skip to main content
Saved

Loading State

Category Resilience

Level Advanced

Examples

React, Vue, Svelte, Native web

Show loading indicators while asynchronous operations are in progress.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Loading State
  5. Button Loading State
  6. Skeleton Screen
  7. Progress Bar
  8. Optimistic Updates
  9. Delayed Loading Indicator
  10. Inline Loading State
  11. Benefits
  12. Tradeoffs
  13. Summary
  14. See also

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 because nothing indicated the app was working. Some refresh pages mid-save assuming the app froze, when it’s still working in the background. Others navigate away and lose their work because there’s no indication they should wait.

On the flip side, content that pops in without any transition causes 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 spinner, skeleton screen, or progress bar shows it. A button that triggers an async operation can disable itself and show “Saving…” so users know their click registered, and content that’s still being fetched gets 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, 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 go 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);
      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>;
}

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-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 wait times are identical.
  • 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 add a short delay before showing loading UI.
  • You need more state in every component: loading, data, and error instead of just data.
  • Skeleton screens need maintenance: when content layout changes, skeletons must update 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 ARIA attributes like role="status" and aria-busy often get left out.

Summary

Loading states tell users your application is working, which prevents confusion and duplicate actions during asynchronous operations. Spinners suit buttons, skeleton screens suit content, and progress bars suit uploads. 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

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.