85 Handling Errors and Loading State on the Frontend
85 Handling Errors and Loading State on the Frontend
Error handling and loading state are two things that are inseparable from modern frontend development. In order to improve UX (user experience) quality, both errors and loading need to be handled consistently and elegantly in every web or mobile application. I believe that the quality of error and loading handling is far more fundamental than the animated effects that are sometimes just “decoration.”
In this article, I’ll share 85 best practices, insights, and code snippets on how to handle errors and loading state on the frontend, complete with React code examples, flow diagrams, and simulations. The goal: to make our applications more robust, secure, and pleasant to use.
Why Are Error and Loading State Important?
Let’s start with two important questions:
- What happens if loading isn’t indicated to the user?
- What if an error occurs but isn’t handled?
The answer is simple: the user will feel like the application is “frozen,” or worse—they’ll just leave, abandoning the application and the developer’s reputation.
Error and loading state aren’t just a visual concern, but also part of our application’s reliability (GX — “Genuine Experience”).
Ways to Handle Error and Loading State
Here is the most fundamental mantra you must hold onto:
We can break down the techniques:
| State | Description | Visual Example |
|---|---|---|
| Loading | Waiting for data/fetch process | Spinner, Skeleton, Dots |
| Success | Data successfully received & displayed | Data view |
| Error | A failure from the backend/network/validation | Banner/Toast/Error Page/Retry |
| Empty | No data exists, but it’s not an error | Empty illustration, Call to Action |
Basic Implementation of Error & Loading Handling in React
Let’s jump straight into a React code example (the principle is nearly the same across all SPA frameworks):
1import { useEffect, useState } from "react";
2
3function fetchData() {
4 return new Promise((resolve, reject) => {
5 setTimeout(() => {
6 // Simulation: random success/error.
7 Math.random() > 0.3
8 ? resolve(["Apple", "Banana", "Orange"])
9 : reject(new Error("Failed fetch data!"));
10 }, 1000);
11 });
12}
13
14export function FruitList() {
15 const [loading, setLoading] = useState(true);
16 const [data, setData] = useState([]);
17 const [error, setError] = useState(null);
18
19 useEffect(() => {
20 fetchData()
21 .then((fruits) => {
22 setData(fruits);
23 setLoading(false);
24 })
25 .catch((err) => {
26 setError(err.message);
27 setLoading(false);
28 });
29 }, []);
30
31 if (loading) return <div>Loading...</div>;
32 if (error) return <div style={{color:"red"}}>Oops: {error}</div>;
33 if (data.length === 0) return <div>No fruits found.</div>;
34
35 return (
36 <ul>
37 {data.map(fruit => <li key={fruit}>{fruit}</li>)}
38 </ul>
39 );
40}Notes:
- We use three states:
loading,data, anderror. - Once the fetch is done, loading is turned off. If there’s an error, display the error message.
- On success but with empty data, show an empty-data message.
Best Practice: Have a Single Source of Truth for “Status”
I recommend encapsulating the state into a single status object:
1const [state, setState] = useState({
2 status: "idle", // or: 'loading', 'success', 'error', 'empty'
3 data: null,
4 error: null
5});The flow looks roughly like this:
flowchart TD
A[Memulai Fetch] --> B{Sukses?}
B -- Ya --> C[Cek Data Kosong?]
C -- Tidak--> D[Status = Success]
C -- Ya --> E[Status = Empty]
B -- Tidak --> F[Status = Error]
A List of “85 Styles” of Handling Errors & Loading on the Frontend
To make this article more practical, here I’ve summarized ideas and best practices related to error and loading state in the form of a table of scenarios and their techniques—short and to the point, 85 of them:
| # | Scenario/Principle | Handling Approach |
|---|---|---|
| 1 | Fetch API | Show a loading spinner |
| 2 | Fetch API error | Show a retry button and error message |
| 3 | Submit form (POST/PUT) | Disabled button + loading indicator |
| 4 | Submit error (e.g. validation) | Highlight the field error |
| 5 | Empty state (no data) | Show an illustration + CTA (action) |
| 6 | Skeleton loading | Use a skeleton when fetching a list/table |
| 7 | Network error | Show an error toast/banner |
| 8 | Timeout | Retry or fallback (cached) |
| 9 | Slow connection | Show a “slow network” warning/loading |
| 10 | Optimistic UI (change state before success) | Revert on failure and show the error |
| 11 | Error boundary (React etc) | Show a friendly fallback UI |
| 12 | Multiple parallel requests | Per-state loading & error |
| 13 | Error 401/403 | Force logout / redirect to login |
| 14 | Error 404 | Show a custom Not Found page |
| 15 | Error tracking | Send the error to a monitoring tool |
| 16 | Polling data | Ensure loading & error state on each tick |
| … | … | … |
| 84 | Layered loading (global & local) | Global loader in the navbar/app shell, local loader in components |
| 85 | Accessible loading animation | Use role=“status”/aria-live so screen readers announce changes |
(The full table can be downloaded on Github ; it contains popular error/loading scenarios on the frontend.)
Simulation: Layered Error Handling in a Dashboard
For example, suppose a dashboard has several widgets that fetch data asynchronously. Let’s simulate error and loading handling per widget:
1function Widget({title, fetchFn}) {
2 const [state, setState] = useState({ status: 'loading', data: null, error: null});
3 useEffect(() => {
4 fetchFn()
5 .then((data) => setState({status: data.length ? 'success' : 'empty', data, error: null}))
6 .catch((err) => setState({status: 'error', data: null, error: err.message}));
7 }, [fetchFn]);
8
9 if (state.status === 'loading') return <div>{title}: Loading...</div>;
10 if (state.status === 'error') return <div>{title}: <span style={{color: 'red'}}>Error: {state.error}</span></div>;
11 if (state.status === 'empty') return <div>{title}: No data</div>;
12 return <div>{title}: Data Ready ✅</div>;
13}
14
15function Dashboard() {
16 const fetchA = () => fetchData();
17 const fetchB = () => Promise.reject(new Error("Service Down"));
18 return (
19 <div>
20 <h2>Dashboard</h2>
21 <Widget title="Widget A" fetchFn={fetchA} />
22 <Widget title="Widget B" fetchFn={fetchB} />
23 </div>
24 );
25}Error Boundary in React (For Robust Render Errors)
For errors that occur while rendering a component, use an error boundary:
1class ErrorBoundary extends React.Component {
2 state = { hasError: false, error: null };
3
4 static getDerivedStateFromError(error) {
5 return {hasError: true, error};
6 }
7
8 componentDidCatch(error, errorInfo) {
9 // Send to Sentry/monitoring, etc.
10 logErrorToService(error, errorInfo);
11 }
12
13 render() {
14 if (this.state.hasError) return <div>Oh Snap! Something went wrong.</div>;
15 return this.props.children;
16 }
17}1<ErrorBoundary>
2 <App />
3</ErrorBoundary>Accessible Loading State
Make sure the loader is readable by screen readers:
1<div role="status" aria-live="polite">
2 Loading fruits, please wait...
3</div>Conclusion: Handling Errors & Loading Is 80% UX!
Besides paying attention to the main flow, error and loading state are the “backend theater” that we need to polish. With the 85 principles and techniques above, your application is guaranteed to look more professional, more reliable—and more loved by users!
Final Tips:
- Don’t show technical errors directly to the user (e.g., a stacktrace).
- Provide a “retry,” and educate the user when needed.
- Build a reusable component for status handling!
Share your error/loading handling experiences in the comments!
See you in the next article. Happy Error Handling! 🚀
References:
For other articles about frontend best practices, see my personal profile on Medium.