JavaScript and React course Β· Module 16: Error Handling and Suspense

Graceful Degradation and Resilient UI

6 min read
In this lesson6

The mission statistics server isn't responding. What should an astronaut see: a blank screen with the message "Error 500", or a dashboard where everything works except one tile saying the statistics will be back shortly? The answer is obvious, and yet many applications choose the first option. On a space station, the failure of one system cannot mean the end of the mission. Every critical system has backups, and the crew is trained to operate in emergency mode. Your React application should work the same way - even if something goes wrong, the user should be able to continue working.

What Is Graceful Degradation?

Graceful degradation is a design philosophy in which an application continues to function even when some of its parts fail - just with a reduced level of functionality. A ship with a damaged telescope keeps flying, it just sees less.

Full Functionality vs. Degradation

Compare two applications. The first shows a white screen on failure, the second contains the failure in one compartment thanks to the Error Boundary from the previous lessons:

1// Instead of: entire page crash
2function BrokenApp() {
3  return <WhiteScreenOfDeath />; // User can't do anything
4}
5
6// Graceful degradation: parts of the application still work
7function ResilientApp() {
8  return (
9    <div>
10      <Navigation />                    {/* Always works */}
11      <ErrorBoundary fallback={<FallbackContent />}>
12        <MainContent />                 {/* May break */}
13      </ErrorBoundary>
14      <Footer />                        {/* Always works */}
15    </div>
16  );
17}

Navigation and Footer sit outside the boundary, so a failure in MainContent doesn't affect them. The user can move to another page instead of reloading the whole application.

Retry Pattern

When an operation fails, give the user the option to try again. With react-error-boundary, all you need is to wire resetErrorBoundary to a button:

1import { ErrorBoundary } from 'react-error-boundary';
2
3function ErrorFallbackWithRetry({ error, resetErrorBoundary }) {
4  return (
5    <div className="error-panel">
6      <h3>Module failure</h3>
7      <p>Error: {error.message}</p>
8      <button onClick={resetErrorBoundary}>
9        Try again
10      </button>
11    </div>
12  );
13}
14
15function App() {
16  return (
17    <ErrorBoundary
18      FallbackComponent={ErrorFallbackWithRetry}
19      onReset={() => {
20        // Clear cache, reset state, etc.
21      }}
22    >
23      <DataPanel />
24    </ErrorBoundary>
25  );
26}

The click clears the boundary and React renders DataPanel from scratch. If the cause of the failure was temporary, the module is back in business, and if it wasn't, the fallback appears again. That's why it's worth clearing, in onReset, any state that might have caused it.

Fallback Content Pattern

Instead of showing an error, show alternative content. The weather widget is not critical, so on failure an honest notice is enough:

1function WeatherWidget() {
2  const [data, setData] = useState(null);
3  const [error, setError] = useState(false);
4
5  useEffect(() => {
6    fetchWeather()
7      .then(setData)
8      .catch(() => setError(true));
9  }, []);
10
11  if (error) {
12    // Instead of an error, show static data
13    return (
14      <div className="weather-widget offline">
15        <p>Weather data unavailable</p>
16        <p>Last update: 2h ago</p>
17        <small>We're working on restoring the connection</small>
18      </div>
19    );
20  }
21
22  if (!data) return <WeatherSkeleton />;
23  return <WeatherDisplay data={data} />;
24}

There is no error boundary here at all: the asynchronous error is caught by .catch(), and the component itself decides what to show. In a real application you'd compute "2h ago" from the saved date of the last successful fetch.

Partial Failure Handling

Handle partial failures - when some data can't be fetched. The key is Promise.allSettled, which waits for all promises and returns, for each one, an object with a status of either 'fulfilled' or 'rejected'. Promise.all would abort everything at the first error:

1function MissionDashboard() {
2  const [missions, setMissions] = useState([]);
3  const [stats, setStats] = useState(null);
4  const [statsError, setStatsError] = useState(false);
5
6  useEffect(() => {
7    // Fetch data in parallel
8    Promise.allSettled([
9      fetch('/api/missions').then(r => r.json()),
10      fetch('/api/stats').then(r => r.json())
11    ]).then(([missionsResult, statsResult]) => {
12      if (missionsResult.status === 'fulfilled') {
13        setMissions(missionsResult.value);
14      }
15      if (statsResult.status === 'fulfilled') {
16        setStats(statsResult.value);
17      } else {
18        setStatsError(true);
19      }
20    });
21  }, []);
22
23  return (
24    <div>
25      <h2>Mission Panel</h2>
26
27      {/* Statistics - may be unavailable */}
28      {statsError ? (
29        <p className="warning">Statistics temporarily unavailable</p>
30      ) : stats ? (
31        <StatsPanel data={stats} />
32      ) : (
33        <StatsSkeleton />
34      )}
35
36      {/* Mission list - always try to display */}
37      <MissionList missions={missions} />
38    </div>
39  );
40}

A statistics failure doesn't touch the mission list: each source has its own success and failure path. One note: fetch doesn't reject the promise on a 500 status, so in production check r.ok too, just like in the first lesson of this location.

Retry with Exponential Backoff

For critical data, implement automatic retries with increasing delays. Exponential backoff means that each subsequent pause is twice as long: 1 s, 2 s, 4 s, 8 s and so on. That way you don't flood an overloaded server with requests. First, the helper function itself:

1async function fetchWithRetry(url, maxRetries = 3) {
2  for (let attempt = 0; attempt < maxRetries; attempt++) {
3    try {
4      const response = await fetch(url);
5      if (!response.ok) throw new Error('Request failed');
6      return await response.json();
7    } catch (error) {
8      if (attempt === maxRetries - 1) throw error;
9      // Exponential backoff: 1s, 2s (with more attempts 4s, 8s...)
10      const delay = Math.pow(2, attempt) * 1000;
11      await new Promise(resolve => setTimeout(resolve, delay));
12    }
13  }
14}

With maxRetries = 3 the function makes three attempts and waits 1 s and 2 s between them. After the last failure it doesn't wait any more, it just rethrows the error. Now the component that uses it and gives the user a manual button when the automation gives up:

1function CriticalData() {
2  const [data, setData] = useState(null);
3  const [error, setError] = useState(null);
4  const [retrying, setRetrying] = useState(false);
5
6  const loadData = async () => {
7    setRetrying(true);
8    setError(null);
9    try {
10      const result = await fetchWithRetry('/api/critical-data');
11      setData(result);
12    } catch (err) {
13      setError(err);
14    } finally {
15      setRetrying(false);
16    }
17  };
18
19  useEffect(() => { loadData(); }, []);
20
21  if (error) return (
22    <div>
23      <p>Failed to fetch data after 3 attempts</p>
24      <button onClick={loadData}>Try again</button>
25    </div>
26  );
27  if (retrying) return <p>Retrying connection...</p>;
28  if (!data) return <LoadingSkeleton />;
29  return <DataDisplay data={data} />;
30}

The order of the conditions in return matters: first the error, then the attempt in progress, and the skeleton last. My advice: use automatic retry only for operations that are safe to repeat, such as reading data, never for sending a payment or a launch command.

Combining Error Boundary with Suspense

Best practice is to combine both mechanisms. The error boundary sits on the outside, Suspense on the inside:

1<ErrorBoundary FallbackComponent={ErrorUI}>
2  <Suspense fallback={<LoadingSkeleton />}>
3    <LazyComponent />
4  </Suspense>
5</ErrorBoundary>

The Error Boundary catches errors, and Suspense handles loading. Together they create a complete safety system. If downloading the code of LazyComponent fails, for example because of a dropped connection, the error reaches the outer boundary instead of leaving an endless skeleton. In the next lesson you'll add monitoring and automatic recovery strategies on top of this.

Remember: a resilient station loses individual modules, but never the whole mission.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Error Boundary with retry
5class RetryBoundary extends React.Component {
6  constructor(props) {
7    super(props);
8    this.state = { hasError: false, error: null };
9  }
10  static getDerivedStateFromError(error) {
11    return { hasError: true, error };
12  }
13  handleRetry = () => {
14    this.setState({ hasError: false, error: null });
15  };
16  render() {
17    if (this.state.hasError) {
18      return (
19        <div className="error-box">
20          <h4>{this.props.label} - Failure</h4>
21          <p>{this.state.error.message}</p>
22          <button onClick={this.handleRetry} className="retry-btn">
23            Retry attempt
24          </button>
25        </div>
26      );
27    }
28    return this.props.children;
29  }
30}
31
32// Simulation of fetchWithRetry
33async function fetchWithRetry(shouldFail, maxRetries = 3) {
34  for (let attempt = 0; attempt < maxRetries; attempt++) {
35    await new Promise(r => setTimeout(r, 500));
36    if (!shouldFail || attempt === maxRetries - 1) {
37      return { status: 'ok', data: 'Data fetched successfully' };
38    }
39  }
40  throw new Error('Failed after multiple attempts');
41}
42
43// Widget z fallback content
44function DataWidget({ title, failChance = 0.3 }) {
45  const [data, setData] = useState(null);
46  const [error, setError] = useState(false);
47  const [loading, setLoading] = useState(true);
48
49  useEffect(() => {
50    setTimeout(() => {
51      if (Math.random() < failChance) {
52        setError(true);
53      } else {
54        setData({ value: Math.floor(Math.random() * 100), unit: '%' });
55      }
56      setLoading(false);
57    }, 800 + Math.random() * 1200);
58  }, []);
59
60  if (loading) return <div className="widget loading">Loading {title}...</div>;
61  if (error) return (
62    <div className="widget offline">
63      <strong>{title}</strong>
64      <p>Data unavailable</p>
65    </div>
66  );
67  return (
68    <div className="widget online">
69      <strong>{title}</strong>
70      <p className="value">{data.value}{data.unit}</p>
71    </div>
72  );
73}
74
75// Module that can fail
76function UnstableModule({ name }) {
77  if (Math.random() < 0.4) {
78    throw new Error(name + ' - critical error');
79  }
80  return (
81    <div className="module-ok">
82      <span className="dot" /> {name}: Online
83    </div>
84  );
85}
86
87export default function App() {
88  const [key, setKey] = useState(0);
89
90  return (
91    <div className="app">
92      <h1>Graceful Degradation</h1>
93      <p className="subtitle">Partial failures don't bring down the whole station</p>
94      <button onClick={() => setKey(k => k + 1)} className="reset-btn">
95        Restart station
96      </button>
97
98      <div key={key}>
99        <section>
100          <h2>Widgets with fallback (Promise.allSettled)</h2>
101          <div className="widget-grid">
102            <DataWidget title="Oxygen" failChance={0.3} />
103            <DataWidget title="Energy" failChance={0.3} />
104            <DataWidget title="Pressure" failChance={0.3} />
105            <DataWidget title="Temp." failChance={0.3} />
106          </div>
107        </section>
108
109        <section>
110          <h2>Modules with Error Boundary + Retry</h2>
111          <div className="modules">
112            <RetryBoundary label="Engine">
113              <UnstableModule name="Main engine" />
114            </RetryBoundary>
115            <RetryBoundary label="Navigation">
116              <UnstableModule name="Navigation" />
117            </RetryBoundary>
118            <RetryBoundary label="Communications">
119              <UnstableModule name="Communications" />
120            </RetryBoundary>
121          </div>
122        </section>
123      </div>
124    </div>
125  );
126}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is graceful degradation in the context of a React application?

  2. 2. How does Promise.allSettled differ from Promise.all?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Implement retry with backoff

  • Vertical ordering

    Order the wait times in exponential backoff (base 1s):

  • Code editor

    Implement partial failure handling

  • Vertical ordering

    Arrange the correct wrapping order (from outermost to innermost):

  • Code editor

    Implement SafeLazyLoader

Useful articles