Error and Loading States
- Explain what loading.tsx and error.tsx do as Next.js App Router file conventions, not imported components
- Build a skeleton loading state shaped like its real content using Tailwind's animate-pulse utility
- Identify the required shape of an error.tsx file: use client, default export, error and reset props
- Explain why error.tsx does not catch errors thrown in the layout or template at the same segment level
- Explain why a logging side effect belongs inside useEffect rather than directly in component render
- Recognize why this app uses two separate loading.tsx files instead of one shared one
From Working to Trustworthy
By the end of Lesson 14 the reputation dashboard works end-to-end: sign in, see reviews, respond, get billed, get emailed. That is the happy path. Real usage is not the happy path -- a database query takes 800ms on a cold serverless start, a network blip throws inside a server component, a review with a long body takes a beat to render. Next.js's App Router has two file conventions built specifically for this: loading.tsx and error.tsx. Both are conventions, not components you import -- drop a file with the right name next to a route segment and Next.js wires it in automatically.
loading.tsx: An Instant Boundary, Not a Spinner You Manage
A loading.tsx file in a route segment wraps that segment's page.tsx in a React Suspense boundary automatically. There is no useState, no manual "is this still fetching" flag -- Next.js shows the loading file the instant navigation starts, and swaps in the real page the moment the server component finishes resolving. The dashboard has two of these. app/dashboard/loading.tsx renders skeleton placeholders shaped like the real content -- filter pills, then list rows:
export default function DashboardLoading() {
return (
{Array.from({ length: 5 }).map((_, i) => (
))}
{Array.from({ length: 4 }).map((_, i) => (
-
))}
);
}
app/dashboard/reviews/[id]/loading.tsx is a second, separate file for the review detail route -- shaped like a single review card instead of a list, because that is what actually renders there. This is the part worth internalizing: loading.tsx only covers its own segment and its children, not the parent layout. app/dashboard/layout.tsx -- the header with the "Reputation Dashboard" title, billing link, and sign-out button -- renders immediately and stays on screen while either loading file is showing underneath it. The user never sees a blank page; they see a stable shell with content streaming in.
Both skeletons use animate-pulse, a single Tailwind utility that pulses opacity on the gray placeholder blocks. There is no custom keyframe animation to write or maintain -- the convention plus one utility class is the entire loading-state implementation.
error.tsx: A Client Component That Catches What Crashes Below It
Where loading.tsx handles slowness, error.tsx handles failure. app/dashboard/error.tsx is the dashboard's error boundary:
"use client";
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Dashboard error:", error);
}, [error]);
return (
Something went wrong
We couldn't load this page. This has been logged; try again, or
come back in a moment.
);
}
Three things about this file are not optional, per the Next.js file-convention spec for error.js/error.tsx: it must be a Client Component (the "use client" directive at the top), it must export a default component, and that component receives exactly two props -- the thrown error object, and a reset() function that re-renders the segment from scratch, giving the user a real retry rather than a forced full-page reload. error.tsx wraps everything below it in its own route segment -- including that segment's loading.tsx -- in a React error boundary, but it does not catch errors thrown in the layout or template file at that same segment level. That is why app/dashboard/layout.tsx -- the header -- keeps rendering even when the page content below it throws: the error boundary sits one level below the layout, not above it.
Why Log Inside useEffect
The console.error call lives inside useEffect rather than directly in the component body. Error boundaries in React can re-render multiple times as part of recovery, and a side effect like logging only belongs in an effect, not inline during render, where it could fire on every render pass instead of once per actual error. In a deployed version of this app, this is also the natural hook point for a monitoring SDK -- Lesson 21 covers wiring one in.
Why Two Separate loading.tsx Files Instead of One
It would be possible to put a single loading state at app/dashboard/loading.tsx and let it cover every nested route, including the review detail page. The reputation dashboard deliberately does not do that, because a list-shaped skeleton and a single-record-shaped skeleton communicate different things to someone watching the screen. Matching the skeleton's shape to the real content it is about to become is what makes a loading state feel fast rather than just present -- the user's eye already knows roughly where the title, badge, and body text are going to land before they arrive.
What This Buys the Reader
Neither file required touching page.tsx, adding a loading library, or writing manual fetch-state logic. That is the actual point of App Router conventions: file placement is the API. Get the file name and location right, and Next.js handles the Suspense boundary and error boundary wiring for you. The next lesson takes the same "make the UI hold up under real conditions" lens and applies it to screen size instead of network timing -- a pass over the same app/dashboard/ components to make sure they hold up on a phone, not just a laptop.
- loading.tsx automatically wraps a route segment's page.tsx in a Suspense boundary -- no manual fetch-state tracking required
- error.tsx must be a Client Component exporting a default component that receives error and reset props
- An error boundary covers its own segment and children but not the layout or template above it, which is why the dashboard header stays visible through an error
- reset() re-renders the failed segment in place; it is a real retry, not a forced full page reload
- Logging inside useEffect avoids re-firing console.error on every recovery re-render of an error boundary
- Skeleton shape should match real content shape (list rows vs. a single record) so the loading state reads as fast, not just present