Zum Inhalt springen
React Anfänger 25 min

Loading & Error States

Lerne, wie du Loading-Zustände und Fehler in React professionell behandelst. Skeleton-Loader, Retry-Logik und Error Boundaries.

Aktualisiert:

Loading & Error States

Eine gute App zeigt dem User immer, was gerade passiert. Wird geladen? Ist ein Fehler aufgetreten? In diesem Tutorial lernst du, wie du Loading- und Error-Zustaende professionell handhabst.

Loading-Zustaende

Einfacher Loading-Spinner

function Spinner() {
  return (
    <div style={{
      display: 'flex',
      justifyContent: 'center',
      padding: '2rem'
    }}>
      <div style={{
        width: '40px',
        height: '40px',
        border: '4px solid #e5e7eb',
        borderTop: '4px solid #3498db',
        borderRadius: '50%',
        animation: 'spin 0.8s linear infinite'
      }} />
      <style>{`
        @keyframes spin {
          to { transform: rotate(360deg); }
        }
      `}</style>
    </div>
  );
}

Skeleton-Loader

Skeleton-Loader zeigen die Form des erwarteten Inhalts – das fuehlt sich schneller an als ein Spinner:

function SkeletonCard() {
  const shimmer = {
    background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
    backgroundSize: '200% 100%',
    animation: 'shimmer 1.5s infinite',
    borderRadius: '4px'
  };

  return (
    <div style={{
      border: '1px solid #eee',
      borderRadius: '12px',
      padding: '1.5rem',
      maxWidth: '350px'
    }}>
      <div style={{ ...shimmer, height: '200px', marginBottom: '1rem', borderRadius: '8px' }} />
      <div style={{ ...shimmer, height: '24px', width: '70%', marginBottom: '0.5rem' }} />
      <div style={{ ...shimmer, height: '16px', width: '100%', marginBottom: '0.25rem' }} />
      <div style={{ ...shimmer, height: '16px', width: '85%', marginBottom: '1rem' }} />
      <div style={{ ...shimmer, height: '36px', width: '120px' }} />
      <style>{`
        @keyframes shimmer {
          0% { background-position: -200% 0; }
          100% { background-position: 200% 0; }
        }
      `}</style>
    </div>
  );
}

// Verwendung
function ProductList() {
  const { data: products, loading, error } = useFetch('/api/products');

  if (loading) {
    return (
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
        <SkeletonCard />
        <SkeletonCard />
        <SkeletonCard />
      </div>
    );
  }

  // Echte Daten anzeigen...
}

Loading-Overlay

Fuer Aktionen, die im Hintergrund laufen:

function LoadingOverlay({ loading, children }) {
  return (
    <div style={{ position: 'relative' }}>
      {children}
      {loading && (
        <div style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          backgroundColor: 'rgba(255,255,255,0.8)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          borderRadius: 'inherit'
        }}>
          <Spinner />
        </div>
      )}
    </div>
  );
}

Error-Zustaende

Benutzerfreundliche Fehlermeldung

function ErrorMessage({ message, onRetry }) {
  return (
    <div style={{
      padding: '2rem',
      textAlign: 'center',
      backgroundColor: '#fef2f2',
      borderRadius: '12px',
      border: '1px solid #fecaca'
    }}>
      <div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>!</div>
      <h3 style={{ color: '#dc2626', marginBottom: '0.5rem' }}>
        Etwas ist schiefgelaufen
      </h3>
      <p style={{ color: '#666', marginBottom: '1rem' }}>
        {message || 'Ein unbekannter Fehler ist aufgetreten.'}
      </p>
      {onRetry && (
        <button
          onClick={onRetry}
          style={{
            padding: '8px 16px',
            backgroundColor: '#dc2626',
            color: 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer'
          }}
        >
          Erneut versuchen
        </button>
      )}
    </div>
  );
}

Retry-Logik

function useFetchWithRetry(url, maxRetries = 3) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [retryCount, setRetryCount] = useState(0);

  async function fetchData() {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    fetchData();
  }, [url, retryCount]);

  function retry() {
    if (retryCount < maxRetries) {
      setRetryCount(prev => prev + 1);
    }
  }

  return { data, loading, error, retry, retryCount, maxRetries };
}

// Verwendung
function DataView() {
  const { data, loading, error, retry, retryCount, maxRetries } =
    useFetchWithRetry('/api/data');

  if (loading) return <Spinner />;

  if (error) {
    return (
      <ErrorMessage
        message={error}
        onRetry={retryCount < maxRetries ? retry : undefined}
      />
    );
  }

  return <div>{/* Daten anzeigen */}</div>;
}

Leere Zustaende

Vergiss nicht den Fall, dass die Daten erfolgreich geladen wurden, aber leer sind:

function EmptyState({ title, message, action, onAction }) {
  return (
    <div style={{
      textAlign: 'center',
      padding: '3rem',
      color: '#666'
    }}>
      <div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📭</div>
      <h3 style={{ marginBottom: '0.5rem' }}>{title}</h3>
      <p style={{ marginBottom: '1rem' }}>{message}</p>
      {action && (
        <button
          onClick={onAction}
          style={{
            padding: '8px 16px',
            backgroundColor: '#3498db',
            color: 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer'
          }}
        >
          {action}
        </button>
      )}
    </div>
  );
}

function TodoList() {
  const { data: todos, loading, error } = useFetch('/api/todos');

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;

  if (!todos || todos.length === 0) {
    return (
      <EmptyState
        title="Keine Todos"
        message="Du hast noch keine Todos erstellt."
        action="Erstes Todo erstellen"
        onAction={() => {/* Todo-Dialog oeffnen */}}
      />
    );
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Alle Zustaende kombinieren

Ein vollstaendiges Pattern fuer Datenlade-Komponenten:

function UserDashboard() {
  const { data: users, loading, error, retry } =
    useFetchWithRetry('https://jsonplaceholder.typicode.com/users');

  // Loading
  if (loading) {
    return (
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
        {[1, 2, 3].map(i => <SkeletonCard key={i} />)}
      </div>
    );
  }

  // Error
  if (error) {
    return <ErrorMessage message={error} onRetry={retry} />;
  }

  // Empty
  if (!users || users.length === 0) {
    return <EmptyState title="Keine Benutzer" message="Es sind keine Benutzer vorhanden." />;
  }

  // Success
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
      {users.map(user => (
        <div key={user.id} style={{
          border: '1px solid #eee',
          borderRadius: '12px',
          padding: '1.5rem'
        }}>
          <h3>{user.name}</h3>
          <p style={{ color: '#666' }}>{user.email}</p>
          <p>{user.company.name}</p>
        </div>
      ))}
    </div>
  );
}

Inline-Loading fuer Aktionen

Fuer Button-Aktionen wie Speichern oder Loeschen:

function ActionButton({ onClick, children, loadingText = 'Laden...' }) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    try {
      await onClick();
    } finally {
      setLoading(false);
    }
  }

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      style={{
        padding: '8px 16px',
        backgroundColor: loading ? '#94a3b8' : '#3498db',
        color: 'white',
        border: 'none',
        borderRadius: '6px',
        cursor: loading ? 'not-allowed' : 'pointer'
      }}
    >
      {loading ? loadingText : children}
    </button>
  );
}

// Verwendung
<ActionButton
  onClick={async () => {
    await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
  }}
  loadingText="Wird gespeichert..."
>
  Speichern
</ActionButton>

Uebungen

  1. Erstelle eine Skeleton-Komponente fuer eine Benutzerliste (Avatar, Name, E-Mail)
  2. Baue eine Fehlerbehandlung mit Retry, die maximal 3 Versuche erlaubt
  3. Implementiere alle vier Zustaende (Loading, Error, Empty, Success) fuer eine Blog-Post-Liste

Was kommt als Naechstes?

Du weisst jetzt, wie du Loading und Errors professionell handhabst. Im naechsten Kapitel lernst du State Management – wie du den State groesserer Apps effizient organisierst und verwaltst.

Zusammenfassung

  • Jeder Datenladevorgang hat vier Zustaende: Loading, Error, Empty, Success
  • Skeleton-Loader fuehlen sich schneller an als Spinner
  • Biete bei Fehlern immer eine Retry-Moeglichkeit an
  • Vergiss nicht den leeren Zustand – zeige hilfreiche Hinweise
  • Nutze Loading-Overlays fuer Hintergrund-Aktionen
  • Inline-Loading in Buttons gibt visuelles Feedback bei Aktionen

Pro-Tipp: Teste deine Loading- und Error-States gezielt! Nutze die Browser DevTools, um die Netzwerk-Geschwindigkeit zu drosseln (Network Tab → Throttling) oder um Fehler zu simulieren. So siehst du genau, wie sich deine App unter schlechten Bedingungen verhaelt!

Zurück zum React Kurs