Error Handling and Loading States in React
Error Handling and Loading States
~11 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
Our ProductListPage from the last chapter has a blind spot: if fetchProducts() fails (network error, server unreachable, wrong URL), the error just ends up in the browser console – the user seemingly sees "Loading products..." forever. Let's fix that properly.
The three states of every data load
From the interface's perspective, every API request always has exactly three possible states you should represent separately:
- Loading – the request is still in flight, usually a spinner or placeholder text
- Error – the request failed, usually an error message plus a "try again" button
- Success – data has arrived and is being displayed
A common beginner mistake is using only ONE loading state and just logging errors – then there's effectively no visible difference between "still loading" and "will never finish because something's broken".
ProductListPage.jsx: adding a real error state
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ProductCard from '../components/ProductCard';
import { fetchProducts } from '../api/magentoApi';
function ProductListPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [query, setQuery] = useState('');
const searchInputRef = useRef(null);
const navigate = useNavigate();
async function loadProducts() {
setLoading(true);
setError(null);
try {
const items = await fetchProducts();
setProducts(items);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
loadProducts();
}, []);
useEffect(() => {
if (!loading && !error) {
searchInputRef.current?.focus();
}
}, [loading, error]);
const filtered = products.filter((product) =>
product.name.toLowerCase().includes(query.toLowerCase())
);
const mostExpensiveProduct = useMemo(() => {
return filtered.reduce(
(max, p) => (p.price > (max?.price ?? 0) ? p : max),
null
);
}, [filtered]);
if (loading) {
return <p>Loading products...</p>;
}
if (error) {
return (
<div>
<p>Could not load products: {error}</p>
<button onClick={loadProducts}>Try again</button>
</div>
);
}
return (
<div>
<input
ref={searchInputRef}
type="text"
placeholder="Search products..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{mostExpensiveProduct && (
<p>Most expensive product: {mostExpensiveProduct.name} (${mostExpensiveProduct.price.toFixed(2)})</p>
)}
<div className="product-grid">
{filtered.map((product) => (
<ProductCard
key={product.sku}
name={product.name}
price={product.price}
imageUrl={product.imageUrl}
onSelect={() => navigate(`/products/${product.sku}`)}
/>
))}
</div>
</div>
);
}
export default ProductListPage;Three important changes in detail: first, loadProducts() is now a standalone function (no longer hidden only inside useEffect) – so the "try again" button can call it directly via onClick={{loadProducts}}. Second, a second useEffect handles auto-focusing the search field EXACTLY when both loading and error are "off" – this fixes last chapter's problem where the field didn't exist yet on the first render. Third, searchInputRef.current?.focus() with optional chaining (?.) additionally guards against the ref still being null if the effect somehow runs before the first render.
Tipp: For our review form (chapters 14–16) we already had similar, but simpler, error handling – there it was about FORM validation errors, here it's about NETWORK errors. Both follow the same underlying principle: errors as their own, visible state, not just a console entry.