Saved
Frontend Pattern

Loading State

Show loading indicators while asynchronous operations are in progress.

Difficulty Advanced

By Den Odell

Loading State

Problem

Users click buttons and see no feedback for seconds, wondering if the app is broken, if they should click again, or if their network connection failed. Interfaces flash from completely empty to full of content instantly, creating disruptive visual jumps that make users lose their place on the page, while users cannot tell whether data is currently loading, failed to load completely, or if there’s simply nothing to show because the query returned no results.

Button presses that trigger save operations provide no indication that anything happened until the operation completes or fails. Form submissions leave users staring at an unchanged form without knowing if their data is being processed, while long-running operations like file uploads or report generation provide no progress indication, leaving users uncertain whether to wait or refresh the page. Rapid repeated clicks occur because users think their first click didn’t register, potentially creating duplicate submissions or corrupted data.

Solution

Display feedback while asynchronous operations complete so users know the application is working and should wait rather than retry.

Show loading spinners, progress bars, or skeleton screens that indicate activity and set expectations. Disable buttons during operations to prevent duplicate submissions.

Use skeleton screens that preserve layout and show content-shaped placeholders for better perceived performance than blank spinners. Display progress percentages for long operations so users understand how long to wait.

Provide immediate visual feedback on button press even if the operation takes time to complete. This prevents confusion and abandonment during data fetching, saves, or processing operations. Track loading state separately from data and error states to manage the full request lifecycle properly.

Example

This example demonstrates managing loading state for an async data fetch, showing a spinner while loading and displaying content when ready.

React Loading States

function UserProfile({ userId }) {
  // Track loading state and user data
  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]);

  // Show spinner while loading
  if (loading) {
    return <div className="spinner" role="status">Loading user...</div>;
  }

  // Show error state
  if (error) {
    return <div className="error">Failed to load user</div>;
  }

  // Show user data once loaded
  return <div>{user.name}</div>;
}

Button Loading State

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

function UserList({ users, loading }) {
  if (loading) {
    return (
      <div className="user-list">
        {/* Show skeleton placeholders that match content shape */}
        {[1, 2, 3].map(i => (
          <div key={i} className="user-skeleton">
            <div className="skeleton-avatar" />
            <div className="skeleton-text skeleton-text-title" />
            <div className="skeleton-text skeleton-text-subtitle" />
          </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: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Progress Bar

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>
  );
}

Vue Loading State

<template>
  <!-- Show spinner while loading -->
  <div v-if="loading" class="spinner" role="status">
    <span class="sr-only">Loading...</span>
  </div>
  
  <!-- Show error state -->
  <div v-else-if="error" class="error">
    {{ error.message }}
  </div>
  
  <!-- Show user data once loaded -->
  <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 () => {
  loading.value = true;
  error.value = null;
  
  try {
    user.value = await fetchUser(props.userId);
  } catch (err) {
    error.value = err;
  } finally {
    loading.value = false;
  }
});
</script>

Svelte Await Block

<script>
  export let userId;
  
  async function loadUser() {
    return await fetchUser(userId);
  }
  
  let userPromise = loadUser();
</script>

{#await userPromise}
  <!-- Show spinner while loading -->
  <div class="spinner">Loading...</div>
{:then user}
  <!-- Show user data once loaded -->
  <div>{user.name}</div>
{:catch error}
  <!-- Show error state -->
  <div class="error">Failed to load user</div>
{/await}

Optimistic Updates

function TodoList({ todos, onToggle }) {
  const [optimisticTodos, setOptimisticTodos] = useState(todos);
  const [loading, setLoading] = useState({});

  const handleToggle = async (todoId) => {
    // Optimistically update UI immediately
    setOptimisticTodos(prev =>
      prev.map(todo =>
        todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
      )
    );
    
    setLoading(prev => ({ ...prev, [todoId]: true }));

    try {
      await toggleTodo(todoId);
    } catch (error) {
      // Revert on error
      setOptimisticTodos(todos);
      alert('Failed to update todo');
    } finally {
      setLoading(prev => ({ ...prev, [todoId]: 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}
          {loading[todo.id] && <span className="spinner-sm" />}
        </li>
      ))}
    </ul>
  );
}

Delayed Loading Indicator

function DataDisplay() {
  const [loading, setLoading] = useState(true);
  const [showSpinner, setShowSpinner] = useState(false);
  const [data, setData] = useState(null);

  useEffect(() => {
    // Only show spinner if loading takes longer than 200ms
    const timer = setTimeout(() => {
      if (loading) {
        setShowSpinner(true);
      }
    }, 200);

    fetchData().then(result => {
      setData(result);
      setLoading(false);
      setShowSpinner(false);
    });

    return () => clearTimeout(timer);
  }, []);

  if (showSpinner) {
    return <Spinner />;
  }

  return data ? <Content data={data} /> : null;
}

Inline Loading State

function SearchBox() {
  const [query, setQuery] = useState('');
  const [searching, setSearching] = useState(false);
  const [results, setResults] = useState([]);

  const handleSearch = async (searchQuery) => {
    setSearching(true);
    try {
      const data = await search(searchQuery);
      setResults(data);
    } finally {
      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(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}

Web Component Loading State

class UserProfile extends HTMLElement {
  constructor() {
    super();
    this.loading = true;
    this.user = null;
    this.error = null;
  }

  async connectedCallback() {
    this.render();
    const userId = this.getAttribute('user-id');

    try {
      this.user = await fetchUser(userId);
    } 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 to load user</div>';
    } else {
      this.innerHTML = `<div>${this.user.name}</div>`;
    }
  }
}

customElements.define('user-profile', UserProfile);

Benefits

  • Provides feedback that the application is working, reducing user confusion and anxiety about whether their action registered.
  • Prevents users from clicking repeatedly or abandoning the operation due to lack of feedback.
  • Improves perceived performance by acknowledging user actions immediately, even if actual processing takes time.
  • Makes it clear when data is loading versus when there’s no data to show, versus when an error occurred.
  • Reduces duplicate submissions by disabling buttons during operations, preventing accidental double-clicks from creating duplicate data.
  • Sets user expectations for wait time through progress indicators, reducing perceived duration of long operations.
  • Provides visual continuity through skeleton screens that maintain layout, preventing jarring content shifts.

Tradeoffs

  • Adds visual complexity with spinners, progress bars, and loading indicators that clutter the interface if overused.
  • Can be disruptive if loading states flash too quickly for fast operations under 200ms, creating visual noise that’s more annoying than helpful.
  • Requires additional state management to track loading status alongside data and error states, increasing component complexity.
  • May need skeleton screens or progressive loading for better UX, which requires maintaining placeholder layouts that match actual content.
  • Skeleton screens require CSS for animations and must be kept in sync with actual content layout, creating maintenance burden when designs change.
  • Optimistic updates add complexity around error handling and state rollback when operations fail after showing success.
  • Progress indicators require backend support to report progress, which may not be available for all operations.
  • Loading state can mask performance problems - instead of optimizing slow operations, developers add spinners and consider it solved.
  • Multiple simultaneous loading states (multiple API calls) require coordinating when to show/hide indicators to avoid spinner spam.
  • Deciding appropriate loading UI (spinner vs skeleton vs progress bar) requires understanding operation duration and user expectations, adding design complexity.
  • Accessibility requires proper ARIA attributes (role="status", aria-busy, aria-live) which are easy to forget or implement incorrectly.
  • Testing loading states requires mocking async operations with delays, which is awkward and makes tests slower.
  • Race conditions occur when operations complete out of order - ensuring the latest request’s loading state is shown requires careful state management.
  • Button disabled states prevent interaction but don’t always clearly communicate why the button is disabled or how long the wait will be.
  • Loading indicators for cached data create unnecessary visual noise - deciding when to skip loading UI for instant results requires caching awareness.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.