This episode covers error handling in Remix: error boundaries for catching render failures, user-friendly error pages, fallback UI for pending and error states, and error reporting at the request level.

Tests from episode 16 cover the known things. But applications also fail in unexpected ways: a database goes down, an external API is slow, code throws an error at 3 AM. Episode 17 ensures those failures don't turn the application into an empty white screen.
Remix has a well-designed error handling model: every route can export an ErrorBoundary to catch rendering errors, and a CatchBoundary in Remix v2 for HTTP responses. When an error occurs, Remix renders the fallback you define — not an empty page.
Episode 17 builds a complete error strategy: per-route boundaries, friendly error pages, fallbacks for pending states, and error reporting to a monitoring service.
An ErrorBoundary catches errors that occur while a component in that route is rendering. It receives error and shows a fallback UI:
export function ErrorBoundary({ error }) {
console.error(error);
return (
<div>
<h1>Terjadi kesalahan</h1>
<p>Maaf, ada yang tidak beres saat memuat halaman ini.</p>
</div>
);
}ErrorBoundary receives the error and renders a replacement UI inside that route. Errors not handled by a route bubble up to the root ErrorBoundary in app/root.tsx.
In Remix v2, CatchBoundary handles 4xx and 5xx responses thrown by loaders, while ErrorBoundary handles unexpected errors. In Remix v3, CatchBoundary is removed — all errors are handled by a single ErrorBoundary by checking response.status. When reading older tutorials, pay attention to the version being used so you don't get confused.
A good error page explains what happened without technical jargon, offers a way out, and never shows a stack trace. A safe combination: a short title, a concise message, and a back or reload button.
import { Link } from "@remix-run/react";
export function ErrorBoundary({ error }) {
return (
<main>
<h1>Halaman tidak bisa dimuat</h1>
<p>Silakan coba lagi dalam beberapa saat.</p>
<Link to="/">Kembali ke beranda</Link>
</main>
);
}A clear action like a Link keeps users from being stuck on the error page. The home page is always a safe place to return to.
Define an ErrorBoundary on routes that are prone to failure — detail pages, dashboards — and let the root provide the final safety net. A layered approach gives local control without losing global safety.
useFetcher exposes fetcher.state with the values idle, submitting, and loading. The UI can use this to show an indicator:
import { useFetcher } from "@remix-run/react";
export default function FormKirim() {
const fetcher = useFetcher();
const sedangKirim = fetcher.state !== "idle";
return (
<fetcher.Form method="post">
<button type="submit" disabled={sedangKirim}>
{sedangKirim ? "Mengirim..." : "Kirim"}
</button>
</fetcher.Form>
);
}fetcher.state changes with the submission cycle; the disabled button prevents double clicks. Visual feedback like this makes the queue feel managed instead of abandoned.
For data streamed with defer, Await provides a fallback while the data isn't ready. Combine it with an error state inside Await to show a message when the promise fails. Every streamed promise needs a pending and an error fallback.
Errors in loaders and actions happen on the server — that's where you log them. Don't just log to the console; send them to a service like Sentry. The practice of logging errors with full request and user context is revisited in episode 22.
export async function loader() {
try {
const data = await ambilData();
return data;
} catch (error) {
console.error("Gagal memuat data", error);
throw error;
}
}Log the error at the point closest to its source, then rethrow so the boundary handles it. Good reporting provides context: when, in which route, and with what kind of request.
Errors that are comfortable to look at are a sign of a mature application. Keep error records as a learning resource for the team, not as a shameful secret. Every well-handled failure is a documented lesson.
Episode 17 makes your application resilient to failure: per-route and global ErrorBoundaries, user-friendly error pages, fallbacks for pending and error states, and request-level error reporting. Failures are now a managed event, not a crisis.
The key takeaways:
In the next episode, episode 18, we'll discuss architecture and maintainability — folder structure for a scalable Remix application, organizing feature modules and routes, shared utilities and typed contracts, and how to keep code maintainable. Errors are handled; now let's make sure the code that handles them is easy to maintain.