You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

Error Handling Architecture

Building resilient UIs with error boundaries and retry logic

Error Handling Architecture in Relay

Master sophisticated error handling patterns in Relay applications with free flashcards and spaced repetition practice. This lesson covers error boundary design, mutation error strategies, network failure recovery, and user feedback systemsβ€”essential concepts for building resilient GraphQL-powered React applications.

Welcome to Error Handling Architecture πŸ’»

Welcome to one of the most critical aspects of production-ready Relay applications! Error handling isn't just about catching exceptionsβ€”it's about creating resilient architectures that gracefully degrade, provide meaningful feedback, and recover intelligently. In this comprehensive lesson, you'll learn how to design error boundaries that protect your component trees, implement sophisticated mutation error handling with optimistic updates and rollbacks, manage network failures with retry strategies, and build user-friendly error notification systems.

Relay's architecture provides powerful primitives for error handling, but knowing how to compose them effectively requires understanding several interconnected patterns. We'll explore field-level errors versus global errors, the difference between recoverable and fatal failures, and how to maintain application state consistency when things go wrong. By the end of this lesson, you'll have a complete mental model for error handling that scales from simple form validation to complex distributed system failures.

Core Concepts: The Error Handling Landscape πŸ—ΊοΈ

Understanding Error Categories in Relay

Relay applications encounter errors across multiple dimensions. Understanding these error taxonomies is crucial for designing appropriate handling strategies:

Network-Level Errors occur during transportβ€”connection timeouts, DNS failures, server unavailability. These are typically transient and benefit from retry strategies with exponential backoff.

GraphQL-Level Errors appear in the response errors array. These include validation failures, authorization issues, and business logic violations. They're often recoverable with user intervention (correcting input, re-authenticating).

Field-Level Errors use GraphQL's error extension model to associate problems with specific fields. This enables granular error handlingβ€”displaying validation messages next to form fields rather than showing generic error banners.

Application-Level Errors happen in your React componentsβ€”rendering errors, state management bugs, unexpected null values. React Error Boundaries catch these to prevent complete application crashes.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           ERROR HANDLING LAYERS                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                 β”‚
β”‚  πŸ“± UI Layer                                    β”‚
β”‚    ↓ Error Boundaries, User Feedback           β”‚
β”‚  βš›οΈ Component Layer                             β”‚
β”‚    ↓ Query/Mutation Hooks, Local State         β”‚
β”‚  πŸ”„ Relay Layer                                 β”‚
β”‚    ↓ Store, Network, Normalization             β”‚
β”‚  🌐 Network Layer                               β”‚
β”‚    ↓ Fetch, Retry, Timeout Logic               β”‚
β”‚  πŸ”Œ GraphQL Server                              β”‚
β”‚    ↓ Resolver Errors, Schema Validation        β”‚
β”‚                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Error Boundary Architecture

React Error Boundaries are class components (or use react-error-boundary library) that catch rendering errors in child components. In Relay applications, strategic boundary placement prevents cascading failures:

// Strategic error boundary placement
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <RelayEnvironmentProvider environment={environment}>
        {/* Critical boundary: protects entire app */}
        <ErrorBoundary FallbackComponent={ModuleFallback}>
          <MainContent />
        </ErrorBoundary>
        {/* Separate boundary: sidebar can fail independently */}
        <ErrorBoundary FallbackComponent={SidebarFallback}>
          <Sidebar />
        </ErrorBoundary>
      </RelayEnvironmentProvider>
    </ErrorBoundary>
  );
}

Granular boundaries isolate failures to specific features. If a user profile widget crashes, the rest of the dashboard remains functional. This is the Bulkhead Pattern from distributed systems applied to UI architecture.

πŸ’‘ Pro Tip: Place error boundaries at feature boundaries, not around every component. Over-segmentation creates visual inconsistency; under-segmentation causes catastrophic failures.

Mutation Error Handling Patterns

Mutations present unique challenges because they modify state. A failed mutation might leave your UI in an inconsistent state if not handled properly. Relay provides several mechanisms:

Optimistic Updates assume success and immediately update the UI. If the mutation fails, Relay automatically rolls back to the previous state:

const [commitMutation, isMutationInFlight] = useMutation(graphql`
  mutation UpdateUserMutation($input: UpdateUserInput!) {
    updateUser(input: $input) {
      user {
        id
        name
        email
      }
      errors {
        field
        message
      }
    }
  }
`);

function handleUpdate(userData) {
  commitMutation({
    variables: { input: userData },
    optimisticResponse: {
      updateUser: {
        user: { ...userData, id: userId },
        errors: null
      }
    },
    onCompleted: (response) => {
      if (response.updateUser.errors) {
        // Handle field-level errors
        displayFieldErrors(response.updateUser.errors);
      } else {
        showSuccessToast('Profile updated!');
      }
    },
    onError: (error) => {
      // Handle network/server errors
      showErrorToast('Failed to update. Please try again.');
      logErrorToService(error);
    }
  });
}

Notice the two-tier error handling: onError for infrastructure failures, checking errors field for business logic failures. This distinction is crucial.

Updater Functions provide fine-grained control over store updates. When mutations fail, you can implement custom rollback logic:

commitMutation({
  variables: { input },
  updater: (store) => {
    const payload = store.getRootField('createPost');
    const newPost = payload.getLinkedRecord('post');
    const root = store.getRoot();
    const posts = root.getLinkedRecords('posts');
    
    // Manually prepend new post
    root.setLinkedRecords([newPost, ...posts], 'posts');
  },
  onError: (error) => {
    // Relay automatically reverts optimistic updates
    // Additional cleanup if needed
  }
});
Network Failure and Retry Strategies

Network failures are inevitable in production. Exponential backoff with jitter prevents thundering herd problems:

import { Network } from 'relay-runtime';

function createNetworkWithRetry() {
  return Network.create(async (operation, variables) => {
    const maxRetries = 3;
    let lastError;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const response = await fetch('/graphql', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ query: operation.text, variables })
        });
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        
        return await response.json();
      } catch (error) {
        lastError = error;
        
        if (attempt < maxRetries) {
          // Exponential backoff: 1s, 2s, 4s
          const delay = Math.pow(2, attempt) * 1000;
          // Add jitter: Β±25%
          const jitter = delay * 0.25 * (Math.random() - 0.5);
          await sleep(delay + jitter);
        }
      }
    }
    
    throw lastError;
  });
}

Jitter prevents synchronized retries when many clients fail simultaneouslyβ€”a common cause of cascading failures.

⚠️ Critical Distinction: Not all errors should trigger retries. Idempotent operations (queries, safe mutations) can retry safely. Non-idempotent operations (payment processing, order creation) require different strategies like idempotency keys:

function commitPaymentMutation(paymentData) {
  const idempotencyKey = generateUUID();
  
  commitMutation({
    variables: { 
      input: { ...paymentData, idempotencyKey } 
    },
    // Network layer can safely retry - server deduplicates by key
  });
}
Field-Level vs Global Error Handling

Modern GraphQL APIs return errors in two places: the top-level errors array for critical failures, and field-specific error objects for validation issues. Your architecture should handle both:

Error TypeLocationHandling StrategyUser Experience
Field ValidationMutation payloadDisplay next to input"Email must be valid"
AuthorizationTop-level errorsRedirect to loginModal or page transition
Rate LimitingTop-level errorsExponential backoff"Please try again in 30s"
Server ErrorTop-level errorsLog & alert"Something went wrong"

Implementing discriminated error handling:

function handleMutationResponse(response) {
  const { data, errors } = response;
  
  // Check top-level errors first
  if (errors && errors.length > 0) {
    errors.forEach(error => {
      if (error.extensions?.code === 'UNAUTHENTICATED') {
        redirectToLogin();
      } else if (error.extensions?.code === 'RATE_LIMITED') {
        const retryAfter = error.extensions.retryAfter;
        showRateLimitMessage(retryAfter);
      } else {
        logToErrorTracking(error);
        showGenericError();
      }
    });
    return;
  }
  
  // Check field-level errors
  if (data?.updateUser?.errors) {
    const fieldErrors = data.updateUser.errors.reduce((acc, err) => {
      acc[err.field] = err.message;
      return acc;
    }, {});
    displayFieldErrors(fieldErrors);
    return;
  }
  
  // Success path
  showSuccessMessage();
}

Example 1: Building a Resilient Form with Comprehensive Error Handling πŸ“

Let's build a complete user profile update form that handles every error scenario gracefully:

import { useMutation, graphql } from 'react-relay';
import { useState } from 'react';

const UpdateProfileMutation = graphql`
  mutation ProfileFormMutation($input: UpdateProfileInput!) {
    updateProfile(input: $input) {
      user {
        id
        name
        email
        bio
      }
      fieldErrors {
        field
        message
      }
    }
  }
`;

function ProfileForm({ user }) {
  const [formData, setFormData] = useState({
    name: user.name,
    email: user.email,
    bio: user.bio
  });
  
  const [fieldErrors, setFieldErrors] = useState({});
  const [globalError, setGlobalError] = useState(null);
  const [saveStatus, setSaveStatus] = useState('idle'); // idle | saving | success | error
  
  const [commitMutation, isMutationInFlight] = useMutation(UpdateProfileMutation);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // Clear previous errors
    setFieldErrors({});
    setGlobalError(null);
    setSaveStatus('saving');
    
    commitMutation({
      variables: { 
        input: { 
          userId: user.id, 
          ...formData 
        } 
      },
      optimisticResponse: {
        updateProfile: {
          user: { 
            id: user.id, 
            ...formData 
          },
          fieldErrors: null
        }
      },
      onCompleted: (response) => {
        const { fieldErrors: errors } = response.updateProfile;
        
        if (errors && errors.length > 0) {
          // Convert array to field map
          const errorMap = errors.reduce((acc, err) => {
            acc[err.field] = err.message;
            return acc;
          }, {});
          
          setFieldErrors(errorMap);
          setSaveStatus('error');
        } else {
          setSaveStatus('success');
          setTimeout(() => setSaveStatus('idle'), 3000);
        }
      },
      onError: (error) => {
        // Network or server error
        setGlobalError(
          'Unable to save changes. Please check your connection and try again.'
        );
        setSaveStatus('error');
        
        // Log to error tracking service
        console.error('Profile update failed:', error);
      }
    });
  };
  
  const handleChange = (field, value) => {
    setFormData(prev => ({ ...prev, [field]: value }));
    // Clear field error when user starts typing
    if (fieldErrors[field]) {
      setFieldErrors(prev => {
        const updated = { ...prev };
        delete updated[field];
        return updated;
      });
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {globalError && (
        <div className="error-banner" role="alert">
          ⚠️ {globalError}
        </div>
      )}
      
      <div className="form-field">
        <label htmlFor="name">Name</label>
        <input
          id="name"
          value={formData.name}
          onChange={(e) => handleChange('name', e.target.value)}
          aria-invalid={!!fieldErrors.name}
          aria-describedby={fieldErrors.name ? 'name-error' : undefined}
        />
        {fieldErrors.name && (
          <span id="name-error" className="field-error" role="alert">
            {fieldErrors.name}
          </span>
        )}
      </div>
      
      <div className="form-field">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={formData.email}
          onChange={(e) => handleChange('email', e.target.value)}
          aria-invalid={!!fieldErrors.email}
          aria-describedby={fieldErrors.email ? 'email-error' : undefined}
        />
        {fieldErrors.email && (
          <span id="email-error" className="field-error" role="alert">
            {fieldErrors.email}
          </span>
        )}
      </div>
      
      <div className="form-field">
        <label htmlFor="bio">Bio</label>
        <textarea
          id="bio"
          value={formData.bio}
          onChange={(e) => handleChange('bio', e.target.value)}
          aria-invalid={!!fieldErrors.bio}
          aria-describedby={fieldErrors.bio ? 'bio-error' : undefined}
        />
        {fieldErrors.bio && (
          <span id="bio-error" className="field-error" role="alert">
            {fieldErrors.bio}
          </span>
        )}
      </div>
      
      <button 
        type="submit" 
        disabled={isMutationInFlight}
      >
        {saveStatus === 'saving' && 'πŸ’Ύ Saving...'}
        {saveStatus === 'success' && 'βœ… Saved!'}
        {saveStatus === 'error' && '❌ Failed'}
        {saveStatus === 'idle' && 'πŸ’Ύ Save Changes'}
      </button>
    </form>
  );
}

Key architectural decisions:

  1. Optimistic updates provide instant feedback for perceived performance
  2. Field-level error state enables granular feedback without re-rendering entire form
  3. Global vs field errors separated for appropriate user feedback
  4. Accessibility attributes (aria-invalid, aria-describedby, role="alert")
  5. Error clearing on user interaction prevents stale error messages

Example 2: Query Error Handling with Suspense Boundaries πŸ”„

Relay's Suspense integration requires coordinated error handling between Error Boundaries and Suspense Boundaries:

import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { usePreloadedQuery, graphql } from 'react-relay';

const DashboardQuery = graphql`
  query DashboardQuery($userId: ID!) {
    user(id: $userId) {
      id
      name
      ...ActivityFeed_user
      ...ProfileStats_user
    }
  }
`;

function DashboardErrorFallback({ error, resetErrorBoundary }) {
  const isNetworkError = error.message.includes('Failed to fetch');
  const isAuthError = error.message.includes('Unauthenticated');
  
  if (isAuthError) {
    return (
      <div className="error-state">
        <h2>πŸ”’ Authentication Required</h2>
        <p>Please log in to view your dashboard.</p>
        <button onClick={() => redirectToLogin()}>Go to Login</button>
      </div>
    );
  }
  
  if (isNetworkError) {
    return (
      <div className="error-state">
        <h2>🌐 Connection Error</h2>
        <p>Unable to load dashboard. Check your internet connection.</p>
        <button onClick={resetErrorBoundary}>Retry</button>
      </div>
    );
  }
  
  return (
    <div className="error-state">
      <h2>⚠️ Something Went Wrong</h2>
      <details>
        <summary>Error details</summary>
        <pre>{error.message}</pre>
      </details>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

function DashboardContent({ queryRef }) {
  const data = usePreloadedQuery(DashboardQuery, queryRef);
  
  if (!data.user) {
    // Handle null data (user deleted, etc.)
    return (
      <div className="empty-state">
        <h2>User Not Found</h2>
        <p>This user may have been deleted.</p>
      </div>
    );
  }
  
  return (
    <div className="dashboard">
      <h1>Welcome, {data.user.name}!</h1>
      
      {/* Isolated error boundaries for independent sections */}
      <div className="dashboard-grid">
        <ErrorBoundary 
          FallbackComponent={SectionErrorFallback}
          onReset={() => console.log('Stats reset')}
        >
          <Suspense fallback={<SkeletonStats />}>
            <ProfileStats user={data.user} />
          </Suspense>
        </ErrorBoundary>
        
        <ErrorBoundary 
          FallbackComponent={SectionErrorFallback}
          onReset={() => console.log('Feed reset')}
        >
          <Suspense fallback={<SkeletonFeed />}>
            <ActivityFeed user={data.user} />
          </Suspense>
        </ErrorBoundary>
      </div>
    </div>
  );
}

function SectionErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="section-error">
      <p>⚠️ This section failed to load</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

export function Dashboard({ queryRef }) {
  return (
    <ErrorBoundary 
      FallbackComponent={DashboardErrorFallback}
      onReset={() => window.location.reload()}
    >
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent queryRef={queryRef} />
      </Suspense>
    </ErrorBoundary>
  );
}

Architecture highlights:

  • Nested error boundaries isolate failures to smallest reasonable unit
  • Error discrimination provides context-appropriate fallbacks
  • Suspense + Error Boundary coordination handles loading AND error states
  • Reset mechanisms allow retry without full page reload

Example 3: Advanced Network Layer with Circuit Breaker Pattern ⚑

For production resilience, implement a circuit breaker that prevents cascading failures:

import { Network } from 'relay-runtime';

class CircuitBreaker {
  constructor({
    failureThreshold = 5,
    resetTimeout = 60000, // 1 minute
    monitoringWindow = 120000 // 2 minutes
  }) {
    this.failureThreshold = failureThreshold;
    this.resetTimeout = resetTimeout;
    this.monitoringWindow = monitoringWindow;
    
    this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
    this.failures = [];
    this.nextRetryTime = null;
  }
  
  recordSuccess() {
    this.failures = [];
    if (this.state === 'HALF_OPEN') {
      this.state = 'CLOSED';
      console.log('🟒 Circuit breaker closed');
    }
  }
  
  recordFailure() {
    const now = Date.now();
    this.failures.push(now);
    
    // Remove old failures outside monitoring window
    this.failures = this.failures.filter(
      time => now - time < this.monitoringWindow
    );
    
    if (this.failures.length >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextRetryTime = now + this.resetTimeout;
      console.log('πŸ”΄ Circuit breaker opened');
    }
  }
  
  async execute(operation) {
    const now = Date.now();
    
    if (this.state === 'OPEN') {
      if (now < this.nextRetryTime) {
        throw new Error(
          `Circuit breaker is OPEN. Retry after ${this.nextRetryTime - now}ms`
        );
      }
      // Transition to half-open for testing
      this.state = 'HALF_OPEN';
      console.log('🟑 Circuit breaker half-open');
    }
    
    try {
      const result = await operation();
      this.recordSuccess();
      return result;
    } catch (error) {
      this.recordFailure();
      throw error;
    }
  }
}

function createResilientNetwork() {
  const circuitBreaker = new CircuitBreaker({
    failureThreshold: 5,
    resetTimeout: 60000
  });
  
  return Network.create(async (operation, variables) => {
    return circuitBreaker.execute(async () => {
      const response = await fetch('/graphql', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${getAuthToken()}`
        },
        body: JSON.stringify({
          query: operation.text,
          variables
        })
      });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      const json = await response.json();
      
      // Check for GraphQL errors that indicate server issues
      if (json.errors) {
        const hasServerError = json.errors.some(
          err => err.extensions?.code === 'INTERNAL_SERVER_ERROR'
        );
        if (hasServerError) {
          throw new Error('GraphQL server error');
        }
      }
      
      return json;
    });
  });
}

Circuit breaker states:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         CIRCUIT BREAKER STATE MACHINE           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚                              β”‚
    β–Ό                              β”‚
  🟒 CLOSED                        β”‚
  (Normal operation)               β”‚
    β”‚                              β”‚
    β”‚ failures β‰₯ threshold         β”‚
    β–Ό                              β”‚
  πŸ”΄ OPEN                          β”‚
  (Reject all requests)            β”‚ success
    β”‚                              β”‚
    β”‚ timeout expires              β”‚
    β–Ό                              β”‚
  🟑 HALF_OPEN                     β”‚
  (Test with one request) β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚
    β”‚ failure
    β–Ό
  πŸ”΄ OPEN

This pattern prevents overwhelming a failing backend, giving it time to recover.

Example 4: User-Friendly Error Notification System πŸ“’

Coordinating error feedback across your application requires a centralized notification system:

import { createContext, useContext, useState, useCallback } from 'react';

const NotificationContext = createContext();

function NotificationProvider({ children }) {
  const [notifications, setNotifications] = useState([]);
  
  const addNotification = useCallback((notification) => {
    const id = Date.now() + Math.random();
    const newNotification = {
      id,
      type: 'info', // info | success | warning | error
      duration: 5000,
      dismissible: true,
      ...notification
    };
    
    setNotifications(prev => [...prev, newNotification]);
    
    if (newNotification.duration) {
      setTimeout(() => {
        removeNotification(id);
      }, newNotification.duration);
    }
    
    return id;
  }, []);
  
  const removeNotification = useCallback((id) => {
    setNotifications(prev => prev.filter(n => n.id !== id));
  }, []);
  
  const notifySuccess = useCallback((message, options = {}) => {
    return addNotification({ type: 'success', message, ...options });
  }, [addNotification]);
  
  const notifyError = useCallback((message, options = {}) => {
    return addNotification({ 
      type: 'error', 
      message, 
      duration: 8000, // Errors stay longer
      ...options 
    });
  }, [addNotification]);
  
  const notifyNetworkError = useCallback(() => {
    return notifyError(
      'Network connection lost. Changes may not be saved.',
      { dismissible: false }
    );
  }, [notifyError]);
  
  return (
    <NotificationContext.Provider value={{
      notifications,
      addNotification,
      removeNotification,
      notifySuccess,
      notifyError,
      notifyNetworkError
    }}>
      {children}
      <NotificationDisplay 
        notifications={notifications} 
        onDismiss={removeNotification}
      />
    </NotificationContext.Provider>
  );
}

function NotificationDisplay({ notifications, onDismiss }) {
  return (
    <div className="notification-container" aria-live="polite">
      {notifications.map(notification => (
        <div 
          key={notification.id}
          className={`notification notification-${notification.type}`}
          role="alert"
        >
          <span className="notification-icon">
            {notification.type === 'success' && 'βœ…'}
            {notification.type === 'error' && '❌'}
            {notification.type === 'warning' && '⚠️'}
            {notification.type === 'info' && 'ℹ️'}
          </span>
          <span className="notification-message">
            {notification.message}
          </span>
          {notification.dismissible && (
            <button 
              className="notification-dismiss"
              onClick={() => onDismiss(notification.id)}
              aria-label="Dismiss notification"
            >
              Γ—
            </button>
          )}
        </div>
      ))}
    </div>
  );
}

export function useNotifications() {
  const context = useContext(NotificationContext);
  if (!context) {
    throw new Error('useNotifications must be used within NotificationProvider');
  }
  return context;
}

// Usage in components:
function SaveButton() {
  const { notifySuccess, notifyError } = useNotifications();
  const [commitMutation] = useMutation(SaveMutation);
  
  const handleSave = () => {
    commitMutation({
      variables: { input: { /* ... */ } },
      onCompleted: () => {
        notifySuccess('Changes saved successfully!');
      },
      onError: (error) => {
        notifyError('Failed to save changes. Please try again.');
      }
    });
  };
  
  return <button onClick={handleSave}>Save</button>;
}

System benefits:

  • Centralized state prevents duplicate notifications
  • Type-specific styling provides visual hierarchy
  • Auto-dismissal with configurable timeouts
  • Accessibility via aria-live and role="alert"
  • Context API makes notifications available anywhere

Common Mistakes to Avoid ⚠️

Mistake 1: Ignoring Partial Success in Mutations

Problem: Treating mutations as binary success/failure when GraphQL can return both data and errors:

// ❌ WRONG: Assumes mutual exclusivity
commitMutation({
  onCompleted: (response) => {
    showSuccess('Saved!');
  },
  onError: (error) => {
    showError('Failed!');
  }
});

Solution: Always check for field-level errors in successful responses:

// βœ… RIGHT: Handles partial success
commitMutation({
  onCompleted: (response) => {
    if (response.updateUser.errors?.length > 0) {
      handleFieldErrors(response.updateUser.errors);
    } else {
      showSuccess('Saved!');
    }
  },
  onError: (error) => {
    showError('Network error. Please retry.');
  }
});
Mistake 2: Overly Aggressive Error Boundaries

Problem: Wrapping every component in an error boundary creates jarring UX:

// ❌ WRONG: Too granular
function UserProfile({ user }) {
  return (
    <div>
      <ErrorBoundary><UserAvatar user={user} /></ErrorBoundary>
      <ErrorBoundary><UserName user={user} /></ErrorBoundary>
      <ErrorBoundary><UserBio user={user} /></ErrorBoundary>
    </div>
  );
}

Solution: Place boundaries at feature/module level:

// βœ… RIGHT: Logical boundaries
function Dashboard() {
  return (
    <div>
      <ErrorBoundary fallback={<ProfileError />}>
        <UserProfile />
      </ErrorBoundary>
      <ErrorBoundary fallback={<FeedError />}>
        <ActivityFeed />
      </ErrorBoundary>
    </div>
  );
}
Mistake 3: Not Providing Recovery Actions

Problem: Showing errors without actionable next steps:

// ❌ WRONG: Dead end
function ErrorFallback({ error }) {
  return <div>Error: {error.message}</div>;
}

Solution: Always provide recovery mechanisms:

// βœ… RIGHT: Actionable feedback
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>
      <button onClick={() => window.location.href = '/'}>Go Home</button>
      <a href="/support">Contact Support</a>
    </div>
  );
}
Mistake 4: Leaking Sensitive Error Information

Problem: Exposing internal implementation details or stack traces to users:

// ❌ WRONG: Exposes internals
function showError(error) {
  alert(`Error: ${error.stack}`);
}

Solution: Log detailed errors, show generic messages:

// βœ… RIGHT: User-friendly + logging
function handleError(error) {
  // Log full details for debugging
  errorTrackingService.log(error);
  
  // Show generic message
  notifyUser('An error occurred. Please try again.');
}
Mistake 5: Not Handling Stale Data After Errors

Problem: Displaying outdated optimistic data after mutation failure:

// ❌ WRONG: No cleanup on error
commitMutation({
  optimisticResponse: { /* ... */ },
  onError: () => {
    showError('Failed');
    // UI still shows optimistic data!
  }
});

Solution: Rely on Relay's automatic rollback and force re-fetch if needed:

// βœ… RIGHT: Ensure fresh data
commitMutation({
  optimisticResponse: { /* ... */ },
  onError: () => {
    // Relay auto-reverts optimistic update
    showError('Failed');
    
    // Optional: force refetch for critical data
    refetch({ /* ... */ }, { fetchPolicy: 'network-only' });
  }
});

Key Takeaways 🎯

  1. Layered Error Handling: Design error handling at multiple levelsβ€”network, GraphQL, field, and UIβ€”with appropriate strategies for each.

  2. Error Boundaries: Place React Error Boundaries at feature boundaries, not around every component. Balance resilience with user experience.

  3. Mutation Complexity: Handle both network errors (onError) and business logic errors (checking response payload). These require different user feedback.

  4. Optimistic Updates: Use optimistic responses for perceived performance, but rely on Relay's automatic rollback for consistency.

  5. Retry Strategies: Implement exponential backoff with jitter for transient failures. Use idempotency keys for non-idempotent operations.

  6. User Feedback: Provide context-appropriate error messages with actionable recovery options. Never leave users in a dead-end state.

  7. Circuit Breakers: Protect your application from cascading failures by implementing circuit breakers in the network layer.

  8. Field-Level Errors: Associate validation errors with specific inputs for better UX. Clear errors when users start correcting them.

  9. Accessibility: Use aria-live, role="alert", and aria-invalid for screen reader support. Error handling is a accessibility concern.

  10. Monitoring: Log errors to tracking services for visibility into production issues. Balance detail (for debugging) with privacy (for users).

πŸ“‹ Quick Reference Card: Error Handling Checklist

ConcernImplementation
Network FailuresRetry with exponential backoff + jitter
Mutation ErrorsCheck both onError AND response.errors
Component CrashesError Boundaries at feature boundaries
Optimistic UpdatesRely on automatic rollback, force refetch if needed
Validation ErrorsDisplay next to fields, clear on user input
Auth FailuresRedirect to login with return URL
Rate LimitingShow retry-after time, implement backoff
User FeedbackToast notifications with icons, auto-dismiss
LoggingFull details to service, generic message to user
RecoveryAlways provide retry/home/support options

πŸ“š Further Study