CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/frontend-error-handling

Proactive error handling for React and vanilla JS frontends — every data-fetching component gets loading, error, and empty states, error boundaries, fetch error handling, form validation, optimistic rollback

90

1.38x
Quality

84%

Does it follow best practices?

Impact

100%

1.38x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/frontend-error-handling/

name:
frontend-error-handling
description:
Proactive error handling for React and vanilla JS frontends. Every component that fetches or displays data MUST have loading, error, and empty states. Every React app needs error boundaries. Every fetch needs network + HTTP + parse error handling. Apply these patterns automatically whenever building frontend components — even when the user does not ask for error handling.
keywords:
error boundary, react error handling, api error handling, loading state, error state, empty state, error message, graceful degradation, try catch frontend, fetch error, network error, user-facing error, toast notification, error recovery, retry, optimistic update, form validation, unhandledrejection
license:
MIT

Frontend Error Handling

Core rule: every component that fetches data needs loading, error, and empty states. Always. Even when the user doesn't ask for them.

Handle errors gracefully in frontend apps — show useful messages, never blank-screen, recover when possible.


The Four States Every Data Component Needs

Any component that fetches data MUST render all four states:

  1. Loading — show a spinner, skeleton, or "Loading..." text while the request is in flight.
  2. Error — show a user-friendly message with a retry button when the request fails. Use role="alert".
  3. Empty — show a meaningful message when the data loads successfully but is empty (e.g., "No orders yet").
  4. Success — render the data normally.

Never skip any of these. A missing loading state means a blank flash. A missing error state means a white screen. A missing empty state means a confusing blank page.


API Call Pattern

Every fetch should handle three failure modes — network error, HTTP error, and parse error:

// src/api.ts
export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
  let res: Response;

  try {
    res = await fetch(path, {
      headers: { 'Content-Type': 'application/json' },
      ...options,
    });
  } catch {
    // Network error — server unreachable, no internet, DNS failure
    throw new Error('Unable to connect to server. Check your connection.');
  }

  if (!res.ok) {
    // HTTP error — server returned 4xx/5xx
    const body = await res.json().catch(() => null);
    const message = body?.error?.message || body?.message || `Request failed (${res.status})`;
    throw new Error(message);
  }

  // Parse error — response isn't valid JSON
  try {
    const body = await res.json();
    return body.data ?? body;
  } catch {
    throw new Error('Invalid response from server.');
  }
}

React: Complete Data-Fetching Component Pattern

function UserProfile() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const load = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await apiRequest<User>('/api/users/me');
      setUser(data);
    } catch (err: unknown) {
      setError(err instanceof Error ? err.message : 'Something went wrong');
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    load();
  }, [load]);

  if (loading) return <div aria-busy="true">Loading profile...</div>;

  if (error) return (
    <div role="alert">
      <p>{error}</p>
      <button onClick={load}>Try again</button>
    </div>
  );

  if (!user) return <p>No profile found.</p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

For list components, always handle the empty array case:

if (items.length === 0) return <p>No items found. Create your first one!</p>;

React Error Boundaries

Catch render-time errors so one broken component doesn't blank the whole page. Every React app must have at least a top-level error boundary, and ideally one per route.

// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { error: Error | null; }

export class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('Component error:', error, info.componentStack);
    // Send to error reporting service in production
  }

  render() {
    if (this.state.error) {
      return this.props.fallback || (
        <div role="alert" style={{ padding: '1rem', border: '1px solid #e00', borderRadius: '4px' }}>
          <h2>Something went wrong</h2>
          <p>{this.state.error.message}</p>
          <button onClick={() => this.setState({ error: null })}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Wrap route-level components:

function App() {
  return (
    <ErrorBoundary>
      <Router>
        <ErrorBoundary fallback={<p>This section failed to load.</p>}>
          <Route path="/profile" element={<UserProfile />} />
        </ErrorBoundary>
        <ErrorBoundary fallback={<p>Dashboard unavailable.</p>}>
          <Route path="/dashboard" element={<Dashboard />} />
        </ErrorBoundary>
      </Router>
    </ErrorBoundary>
  );
}

Global Error Handlers

Catch unhandled errors and promise rejections at the window level as a safety net:

// src/global-error-handler.ts
window.addEventListener('error', (event) => {
  console.error('Unhandled error:', event.error);
  // Report to error tracking service
});

window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);
  // Report to error tracking service
});

Form Validation and Submission Errors

Forms need both client-side validation and server error handling:

function ContactForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitError, setSubmitError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    // Client-side validation
    const newErrors: Record<string, string> = {};
    if (!formData.get('email')) newErrors.email = 'Email is required';
    if (!formData.get('message')) newErrors.message = 'Message is required';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    setSubmitting(true);
    setSubmitError(null);
    try {
      await apiRequest('/api/contact', {
        method: 'POST',
        body: JSON.stringify(Object.fromEntries(formData)),
      });
    } catch (err: unknown) {
      setSubmitError(err instanceof Error ? err.message : 'Failed to send message');
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {submitError && <div role="alert">{submitError}</div>}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email"
        aria-invalid={!!errors.email} aria-describedby="email-error" />
      {errors.email && <span id="email-error" role="alert">{errors.email}</span>}
      {/* ... more fields ... */}
      <button type="submit" disabled={submitting}>
        {submitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Optimistic Updates with Rollback

When updating data optimistically, always handle the rollback case:

async function toggleFavorite(itemId: string) {
  // Save previous state for rollback
  const previousItems = [...items];

  // Optimistic update
  setItems(items.map(item =>
    item.id === itemId ? { ...item, favorite: !item.favorite } : item
  ));

  try {
    await apiRequest(`/api/items/${itemId}/favorite`, { method: 'POST' });
  } catch (err) {
    // Rollback on failure
    setItems(previousItems);
    showToast('Failed to update. Please try again.');
  }
}

Retry Mechanisms

For critical operations, implement automatic retry with backoff:

async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  retries = 3,
  delay = 1000
): Promise<T> {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === retries - 1) throw err;
      await new Promise(resolve => setTimeout(resolve, delay * (attempt + 1)));
    }
  }
  throw new Error('Unreachable');
}

User-Facing Error Messages

Never show raw error objects, stack traces, or technical messages to users:

function getUserMessage(error: unknown): string {
  const msg = error instanceof Error ? error.message.toLowerCase() : '';

  if (msg.includes('unable to connect') || msg.includes('network') || msg.includes('failed to fetch')) {
    return 'Can\'t reach the server. Check your internet connection.';
  }
  if (msg.includes('not found') || msg.includes('404')) {
    return 'The item you\'re looking for doesn\'t exist.';
  }
  if (msg.includes('unauthorized') || msg.includes('401')) {
    return 'Please sign in to continue.';
  }
  if (msg.includes('forbidden') || msg.includes('403')) {
    return 'You don\'t have permission to do that.';
  }
  if (msg.includes('rate limit') || msg.includes('429') || msg.includes('too many')) {
    return 'Too many requests. Please wait a moment and try again.';
  }

  return error instanceof Error && error.message
    ? error.message
    : 'Something went wrong. Please try again.';
}

Toast / Notification Pattern

For non-blocking errors (failed action, but the page still works):

function showToast(message: string, type: 'error' | 'success' = 'error') {
  const toast = document.getElementById('toast')!;
  toast.textContent = message;
  toast.className = `toast toast-${type} toast-visible`;
  setTimeout(() => { toast.className = 'toast'; }, 5000);
}

// HTML: <div id="toast" role="status" aria-live="polite" class="toast"></div>

Use toasts for: failed non-critical actions, success confirmations, transient network issues. Use full-page error states for: failed initial data load, auth failures, critical errors.


Vanilla JS: Complete Pattern

async function loadOrders() {
  const container = document.getElementById('orders')!;
  container.innerHTML = '<p aria-busy="true">Loading orders...</p>';

  try {
    const orders = await apiRequest<Order[]>('/api/orders');

    if (orders.length === 0) {
      container.innerHTML = '<p>No orders yet. Place your first order!</p>';
      return;
    }

    container.innerHTML = renderOrders(orders);
  } catch (err) {
    container.innerHTML = `
      <div role="alert" class="error-message">
        <p>${escapeHtml(getUserMessage(err))}</p>
        <button onclick="loadOrders()">Try again</button>
      </div>
    `;
  }
}

Checklist — Apply to EVERY Component That Fetches Data

  • Loading state shown while fetching (spinner, skeleton, or text)
  • Error state with user-friendly message and retry button
  • Error state uses role="alert" for accessibility
  • Empty state when data array is empty (not blank page)
  • Fetch handles network errors (try/catch around fetch)
  • Fetch handles HTTP errors (check res.ok)
  • Fetch handles parse errors (try/catch around res.json())
  • Error boundary around route-level React components
  • No raw error objects or stack traces shown to users
  • Form submissions handle server errors and show feedback
  • Form has client-side validation with accessible error messages
  • Optimistic updates include rollback on failure
  • Toast/notification for non-blocking errors

Verifiers

  • api-errors-handled — User profile page: loading, error, and fetch error handling
  • dashboard-data-display — Dashboard: loading, error, empty states, error boundary
  • list-page-with-fetch — Product listing: loading, error, empty states, fetch handling
  • form-submission — Contact form: submission errors, validation, loading state
  • interactive-action-with-api — Todo CRUD: mutation errors, optimistic rollback
  • search-with-api — Search: loading, error, no-results states

skills

frontend-error-handling

tile.json