Error Screens and Empty States
A production-quality app handles three failure cases gracefully: the server returns an error, the requested resource doesn’t exist, and React itself throws an unexpected error. This lesson adds all three.
The 404 page
Section titled “The 404 page”When a user navigates to a URL that matches no route, React Router renders nothing. Add a catch-all route to show a helpful message:
import { Link } from 'react-router-dom'
export default function NotFound() { return ( <main> <h1>Page Not Found</h1> <p>The page you're looking for doesn't exist.</p> <Link to="/">Back to feed</Link> </main> )}Register it as the last route in your router — the * path matches anything not caught above:
// src/App.tsx (routes excerpt)<Route path="*" element={<NotFound />} />Order matters: React Router tries routes from top to bottom. The * route must be last.
Distinguishing 404 from network errors
Section titled “Distinguishing 404 from network errors”When getPost(id) fails, was it a 404 (post doesn’t exist) or a 500 (server error)? Axios puts the HTTP status on the error:
import axios from 'axios'
getPost(Number(id)) .then(setPost) .catch(err => { if (axios.isAxiosError(err) && err.response?.status === 404) { setError('This post does not exist.') } else { setError('Failed to load post. Please try again.') } }) .finally(() => setLoading(false))Different messages for different failures help users understand what went wrong and what to do next.
Empty states
Section titled “Empty states”An empty state is a message shown when a list has no items — not an error, just nothing there yet. You’ve already used these:
{posts.length === 0 && <p>No posts yet. Be the first!</p>}{profile.posts.length === 0 && <p>No posts yet.</p>}{post.comments.length === 0 && <p>No comments yet.</p>}Good empty states:
- Explain why the list is empty
- Guide the user toward an action (“Be the first!”)
- Never look like an error
React Error Boundary
Section titled “React Error Boundary”React’s normal error handling stops at component boundaries — an unhandled throw in a component will crash the entire app with a blank white screen. An Error Boundary catches errors in its child tree and renders a fallback UI instead.
Error Boundaries must be class components:
import { Component, ReactNode } from 'react'
interface Props { children: ReactNode }interface State { hasError: boolean }
export default class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false }
static getDerivedStateFromError(): State { return { hasError: true } }
render() { if (this.state.hasError) { return ( <main> <h1>Something went wrong</h1> <p>An unexpected error occurred. Please refresh the page.</p> </main> ) } return this.props.children }}Wrap your app’s content with it:
// src/main.tsx or src/App.tsximport ErrorBoundary from './components/ErrorBoundary'
<ErrorBoundary> <RouterProvider router={router} /></ErrorBoundary>Error Boundaries catch runtime errors in the render tree — they do not catch errors in event handlers or async code (those are caught by try/catch in your handlers).
A loading skeleton vs a spinner
Section titled “A loading skeleton vs a spinner”A loading spinner says “something is happening.” A skeleton screen shows the shape of the content while it loads — users perceive it as faster because they see layout immediately:
// Simple skeleton for a post list item:function PostSkeleton() { return ( <li style={{ opacity: 0.4 }}> <span style={{ background: '#eee', display: 'inline-block', width: 200, height: 16 }} /> </li> )}
// In PostFeed when loading:if (loading) return ( <ul> {Array.from({ length: 5 }).map((_, i) => <PostSkeleton key={i} />)} </ul>)Skeletons are a polish touch — implement them if you have time, but a plain <p>Loading...</p> is perfectly fine for Bulletin.
Exercise
Section titled “Exercise”Open src/pages/NotFound.tsx in your Bulletin project (create the file).
- Create
NotFound.tsxand register it as thepath="*"catch-all route inApp.tsx. - Update
PostDetail.tsxto distinguish 404 from other errors usingaxios.isAxiosError. - Create
ErrorBoundary.tsxand wrap your app’s router or main content with it. - Test each case:
- Navigate to
/posts/99999(nonexistent ID) — confirm you see “This post does not exist.” - Navigate to
/does-not-exist— confirm you see the NotFound page.
- Navigate to
- Register a
path="*"catch-all route last — it matches any unhandled URL. axios.isAxiosError(err) && err.response?.status === 404distinguishes not-found from server errors.- Empty states explain the absence of content and guide the user — never look like errors.
ErrorBoundarycatches unhandled render errors and shows a fallback instead of a blank screen.