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

Main Project: Resilient Space Dashboard

6 min read
In this lesson5

Mission control has one last assignment for you in this location. The dashboard the whole crew is watching can't go dark just because one sensor sent garbage data or the statistics server took a day off. Time to combine all the techniques you've learned in a single project! You'll create a fault-tolerant space dashboard that demonstrates Error Boundaries, Suspense, skeleton loaders, graceful degradation, and retry patterns.

Project Requirements

Your dashboard should include:

  1. Application-level Error Boundary - catches critical errors
  2. Section-level Error Boundaries - isolate module failures
  3. Suspense with skeleton loaders - smooth component loading
  4. Retry mechanism - ability to retry after an error
  5. Graceful degradation - partial failures don't destroy the entire application

Each point is one of the previous lessons, so you don't have to invent anything from scratch. Your task is to assemble these pieces so that they work together.

Project Architecture

Before you write your first component, sketch the tree. The outer AppErrorBoundary boundary is the last line of defense, and each section gets its own pair: an error boundary on the outside and Suspense on the inside, in exactly the order you learned when combining both mechanisms:

1// Resilient Space Dashboard structure
2<AppErrorBoundary>
3  <SpaceDashboard>
4    <Header />
5
6    <ErrorBoundary fallback={<MissionFallback />}>
7      <Suspense fallback={<MissionSkeleton />}>
8        <MissionControl />
9      </Suspense>
10    </ErrorBoundary>
11
12    <ErrorBoundary fallback={<SystemFallback />}>
13      <Suspense fallback={<SystemSkeleton />}>
14        <SystemStatus />
15      </Suspense>
16    </ErrorBoundary>
17
18    <ErrorBoundary fallback={<CrewFallback />}>
19      <CrewPanel />
20    </ErrorBoundary>
21  </SpaceDashboard>
22</AppErrorBoundary>

Header sits outside the section boundaries, so it stays on screen even when all three modules go dark. CrewPanel has no Suspense, because in this sketch it's imported normally, not through React.lazy().

Key Elements to Implement

1. Reusable Error Boundary with Retry

Start with a class that, besides the fallback, has a handleRetry method. A class field written as an arrow function automatically has the correct this, so you can wire it directly to onClick:

1class RetryErrorBoundary extends React.Component {
2  constructor(props) {
3    super(props);
4    this.state = { hasError: false, error: null };
5  }
6
7  static getDerivedStateFromError(error) {
8    return { hasError: true, error };
9  }
10
11  handleRetry = () => {
12    this.setState({ hasError: false, error: null });
13  };
14
15  render() {
16    if (this.state.hasError) {
17      return (
18        <div className="error-panel">
19          <h3>{this.props.title || 'Module failure'}</h3>
20          <p>{this.state.error.message}</p>
21          <button onClick={this.handleRetry}>Restart</button>
22        </div>
23      );
24    }
25    return this.props.children;
26  }
27}

Clearing hasError makes render() return the children again. Since they were unmounted during the failure, they start with a clean state, and that is exactly a module restart. The title prop lets you name each section in the fallback.

2. Section Skeleton Loader

A universal skeleton takes a number of lines. We compute each line's width from its index, so the lines look naturally uneven:

1function SectionSkeleton({ lines = 3 }) {
2  return (
3    <div className="section-skeleton">
4      <div className="skeleton-header skeleton-pulse" />
5      {Array.from({ length: lines }).map((_, i) => (
6        <div
7          key={i}
8          className="skeleton-line skeleton-pulse"
9          style={{ width: `${60 + ((i * 17) % 40)}%` }}
10        />
11      ))}
12    </div>
13  );
14}

We deliberately avoid Math.random() here: a React component should be pure, and a random width would change on every re-render, making the skeleton "twitch". The index formula always gives the same values, from 60% to 99%.

3. Component with Error Simulation

To test the boundaries, you need a module that sometimes breaks. Watch out for a trap: an error thrown inside setTimeout would bypass the Error Boundary, because it happens outside rendering. That's why we store the failure in state and throw the error only during render:

1function UnstableModule({ failRate = 0.3 }) {
2  const [data, setData] = useState(null);
3  const [failed, setFailed] = useState(false);
4
5  useEffect(() => {
6    // Simulating an unstable API
7    const timer = setTimeout(() => {
8      if (Math.random() < failRate) {
9        // An error thrown here would bypass the Error Boundary,
10        // so we only store the failure in state
11        setFailed(true);
12        return;
13      }
14      setData({ status: 'operational', power: 98 });
15    }, 1000);
16    return () => clearTimeout(timer);
17  }, [failRate]);
18
19  // Throw during rendering - the Error Boundary will catch this
20  if (failed) throw new Error('Module malfunction detected!');
21
22  if (!data) return <p>Initializing...</p>;
23  return (
24    <div className="module-status">
25      <span>Status: {data.status}</span>
26      <span>Power: {data.power}%</span>
27    </div>
28  );
29}

Math.random() is fine here, because it runs in an effect, not during rendering. Set failRate={1} to force a failure and check that the fallback appears in one section only.

4. Graceful Degradation - Partial Functionality

When one module fails, the rest of the dashboard should work normally. The section fallback says plainly what is offline and reassures that the rest is working:

1function FallbackPanel({ title, onRetry }) {
2  return (
3    <div className="fallback-panel">
4      <h3>{title} - Offline</h3>
5      <p>This module is temporarily unavailable.</p>
6      <p>Other systems are operating normally.</p>
7      <button onClick={onRetry}>Retry</button>
8    </div>
9  );
10}

You can connect the onRetry prop to resetErrorBoundary from the library or to handleRetry from your own class.

5. Retry with Attempt Limit

The last element is a hook that makes sure the crew doesn't click "Retry" forever:

1function useRetry(asyncFn, maxRetries = 3) {
2  const [retryCount, setRetryCount] = useState(0);
3  const [error, setError] = useState(null);
4
5  const retry = async () => {
6    if (retryCount >= maxRetries) {
7      setError(new Error('Retry limit exceeded'));
8      return;
9    }
10    try {
11      setRetryCount(prev => prev + 1);
12      await asyncFn();
13      setError(null);
14    } catch (err) {
15      setError(err);
16    }
17  };
18
19  return { retry, error, retryCount, canRetry: retryCount < maxRetries };
20}

The hook returns canRetry, so you can simply disable the button when the limit runs out, instead of showing an error only after the click.

Implementation Tips

  • Use class ErrorBoundary extends React.Component for custom Error Boundaries
  • Give each module its own Error Boundary - one failure shouldn't destroy the entire dashboard
  • Add a "Restart module" button in the fallback UI of each Error Boundary
  • Create reusable skeleton loaders that match each section
  • Combine Error Boundary + Suspense for dynamically loaded components
  • Add space-themed styling - dark background, neon accents, space station theme

My advice: instead of writing three classes, use a single RetryErrorBoundary or ErrorBoundary from react-error-boundary and change only the title and the fallback. Less code means fewer places for failure.

Evaluation Criteria

  1. Application-level Error Boundary - catches critical errors
  2. At least 3 sections with separate Error Boundaries - each can "break" independently
  3. Suspense used with at least one lazy-loaded component
  4. Skeleton loader - visible during loading
  5. Retry button - ability to retry after an error
  6. Graceful degradation - the rest of the dashboard works despite one section failing

Review the editor below with a sample implementation and modify it to suit your needs! You'll send the finished project to your mentor in the next task, and the ability to build resilient interfaces will pay off in every future application, from forms to dashboards with live data.

Remember: a truly resilient mission dashboard switches off individual controls, but never leaves the crew in the dark.

Code for this lesson: App.jsx
1import React, { useState, useEffect, Suspense } from 'react';
2import './styles.css';
3
4// === Error Boundary with retry ===
5class ErrorBoundary 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  componentDidCatch(error, info) {
14    console.error('[' + (this.props.section || '?') + ']', error.message);
15  }
16  handleRetry = () => {
17    this.setState({ hasError: false, error: null });
18  };
19  render() {
20    if (this.state.hasError) {
21      return (
22        <div className="err-box">
23          <h4>{this.props.section || 'Module'} - Failure</h4>
24          <p>{this.state.error.message}</p>
25          <button onClick={this.handleRetry} className="retry-btn">Restart</button>
26        </div>
27      );
28    }
29    return this.props.children;
30  }
31}
32
33// === Skeleton Loaders ===
34function CardSkeleton() {
35  return (
36    <div className="skel-card">
37      <div className="skel-h skeleton-pulse" />
38      <div className="skel-p skeleton-pulse" />
39      <div className="skel-p skeleton-pulse short" />
40    </div>
41  );
42}
43
44function ListSkeleton({ count = 3 }) {
45  return (
46    <div className="skel-list">
47      {Array.from({ length: count }).map((_, i) => (
48        <div key={i} className="skel-row">
49          <div className="skel-dot skeleton-pulse" />
50          <div className="skel-line skeleton-pulse" />
51        </div>
52      ))}
53    </div>
54  );
55}
56
57// === Lazy Components ===
58const MissionControl = React.lazy(() =>
59  new Promise(resolve =>
60    setTimeout(() => resolve({
61      default: function MissionCtrl() {
62        const missions = [
63          { id: 1, name: 'Mars Exploration', status: 'active' },
64          { id: 2, name: 'Jupiter Probe', status: 'pending' },
65          { id: 3, name: 'Lunar Base', status: 'completed' },
66        ];
67        return (
68          <div className="panel">
69            <h3>Mission Center</h3>
70            {missions.map(m => (
71              <div key={m.id} className={`mission-item ${m.status}`}>
72                <span>{m.name}</span>
73                <span className="badge">{m.status}</span>
74              </div>
75            ))}
76          </div>
77        );
78      }
79    }), 1500)
80  )
81);
82
83// Unstable component for testing Error Boundary
84function SystemStatus() {
85  const [systems] = useState(() => {
86    return [
87      { id: 1, name: 'Engine', level: Math.floor(Math.random() * 30) + 70 },
88      { id: 2, name: 'Oxygen', level: Math.floor(Math.random() * 20) + 80 },
89      { id: 3, name: 'Energy', level: Math.floor(Math.random() * 40) + 60 },
90    ];
91  });
92
93  // 30% chance of failure
94  if (Math.random() < 0.3) {
95    throw new Error('Diagnostic system failure!');
96  }
97
98  return (
99    <div className="panel">
100      <h3>System Status</h3>
101      {systems.map(s => (
102        <div key={s.id} className="sys-row">
103          <span>{s.name}</span>
104          <div className="bar-bg">
105            <div
106              className="bar-fill"
107              style={{
108                width: s.level + '%',
109                background: s.level > 70 ? '#4caf50' : '#ff9800',
110              }}
111            />
112          </div>
113          <span className="pct">{s.level}%</span>
114        </div>
115      ))}
116    </div>
117  );
118}
119
120// Crew panel with async data
121function CrewPanel() {
122  const [crew, setCrew] = useState(null);
123  const [error, setError] = useState(false);
124
125  useEffect(() => {
126    setTimeout(() => {
127      if (Math.random() < 0.2) {
128        setError(true);
129      } else {
130        setCrew([
131          { id: 1, name: 'Capt. Nova', role: 'Commander', status: 'online' },
132          { id: 2, name: 'Dr. Stellar', role: 'Science', status: 'online' },
133          { id: 3, name: 'Eng. Orion', role: 'Technical', status: 'busy' },
134        ]);
135      }
136    }, 1000);
137  }, []);
138
139  if (error) {
140    return (
141      <div className="panel offline-panel">
142        <h3>Crew</h3>
143        <p className="offline-msg">Crew data temporarily unavailable</p>
144      </div>
145    );
146  }
147
148  if (!crew) {
149    return (
150      <div className="panel">
151        <h3>Crew</h3>
152        <ListSkeleton count={3} />
153      </div>
154    );
155  }
156
157  return (
158    <div className="panel">
159      <h3>Crew</h3>
160      {crew.map(c => (
161        <div key={c.id} className="crew-row">
162          <span className={`crew-dot ${c.status}`} />
163          <strong>{c.name}</strong>
164          <span className="crew-role">{c.role}</span>
165        </div>
166      ))}
167    </div>
168  );
169}
170
171export default function App() {
172  const [key, setKey] = useState(0);
173
174  return (
175    <div className="app">
176      <header className="header">
177        <h1>Resilient Space Dashboard</h1>
178        <button onClick={() => setKey(k => k + 1)} className="reset-btn">
179          Restart
180        </button>
181      </header>
182
183      <div className="dashboard" key={key}>
184        {/* Mission Control: Suspense + Error Boundary */}
185        <ErrorBoundary section="Missions">
186          <Suspense fallback={<CardSkeleton />}>
187            <MissionControl />
188          </Suspense>
189        </ErrorBoundary>
190
191        {/* System Status: Error Boundary with retry */}
192        <ErrorBoundary section="Systems">
193          <SystemStatus />
194        </ErrorBoundary>
195
196        {/* Crew Panel: Graceful degradation */}
197        <ErrorBoundary section="Crew">
198          <CrewPanel />
199        </ErrorBoundary>
200      </div>
201    </div>
202  );
203}

Spotted a mistake in this lesson?

Useful articles