Skip to content

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.

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:

src/pages/NotFound.tsx
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.

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.

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’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:

src/components/ErrorBoundary.tsx
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.tsx
import 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 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.

Open src/pages/NotFound.tsx in your Bulletin project (create the file).

  1. Create NotFound.tsx and register it as the path="*" catch-all route in App.tsx.
  2. Update PostDetail.tsx to distinguish 404 from other errors using axios.isAxiosError.
  3. Create ErrorBoundary.tsx and wrap your app’s router or main content with it.
  4. 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.
  • Register a path="*" catch-all route last — it matches any unhandled URL.
  • axios.isAxiosError(err) && err.response?.status === 404 distinguishes not-found from server errors.
  • Empty states explain the absence of content and guide the user — never look like errors.
  • ErrorBoundary catches unhandled render errors and shows a fallback instead of a blank screen.