Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ const LearnActionsPage = lazy(() =>
default: m.LearnActionsPage,
})),
);
const LearnErrorHandlingPage = lazy(() =>
import("./pages/LearnErrorHandlingPage.js").then((m) => ({
default: m.LearnErrorHandlingPage,
})),
);
const LearnTransitionsPage = lazy(() =>
import("./pages/LearnTransitionsPage.js").then((m) => ({
default: m.LearnTransitionsPage,
Expand Down Expand Up @@ -210,6 +215,12 @@ export const routes = [
name: "LearnActionsPage",
}),
}),
route({
path: "/error-handling",
component: defer(<LearnErrorHandlingPage />, {
name: "LearnErrorHandlingPage",
}),
}),
route({
path: "/transitions",
component: defer(<LearnTransitionsPage />, {
Expand Down
148 changes: 148 additions & 0 deletions packages/docs/src/pages/LearnErrorHandlingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { CodeBlock } from "../components/CodeBlock.js";

export function LearnErrorHandlingPage() {
return (
<div className="learn-content">
<h2>Error Handling</h2>

<p className="page-intro">
The recommended way to handle route errors is to place an Error Boundary
inside a layout route and wrap its <code>{"<Outlet />"}</code>. That
keeps shared UI like headers and navigation visible while errors from
child routes fall back to a recovery screen.
</p>

<section>
<h3>Put the Boundary in a Layout Route</h3>
<p>
In FUNSTACK Router, child routes render inside their parent
route&rsquo;s <code>{"<Outlet />"}</code>. This makes a root layout a
great place for a top-level Error Boundary: it can catch errors from
all nested routes without replacing the layout itself.
</p>
<p>
A boundary <strong>outside</strong> the Router can still act as a
last-resort safeguard, but it cannot use router hooks like{" "}
<code>useLocation()</code> to reset on navigation. For day-to-day
route errors, prefer a boundary <strong>inside</strong> your root
layout:
</p>
<CodeBlock language="tsx">{`import { Outlet, useLocation } from "@funstack/router";
import { ErrorBoundary } from "react-error-boundary";

function RootLayout() {
const { entryId } = useLocation();

return (
<div>
<header>My App</header>
<nav>
<a href="/">Home</a>
<a href="/users/1">User</a>
</nav>

<ErrorBoundary
resetKeys={[entryId]}
fallbackRender={() => <p>Something went wrong.</p>}
>
<Outlet />
</ErrorBoundary>
</div>
);
}`}</CodeBlock>
<p>
With this structure, an error in a child page, child loader, or nested
layout below <code>RootLayout</code> shows the fallback UI while the
outer layout stays mounted.
</p>
</section>

<section>
<h3>Reset the Boundary on Navigation</h3>
<p>
Error Boundaries keep showing their fallback until they are reset. The
easiest way to reset them when the user navigates is to key that reset
off <code>useLocation().entryId</code>.
</p>
<p>
<code>entryId</code> comes from the Navigation API&rsquo;s current
history entry. It changes when the router moves to a different entry,
so using it in <code>resetKeys</code> lets the boundary recover when
the user clicks another link or goes back/forward to a different page.
</p>
<p>
If you place more boundaries deeper in the tree, apply the same
pattern there as well. The nearest boundary above the failing route is
the one that catches the error.
</p>
</section>

<section>
<h3>How Loader Errors Propagate</h3>
<p>
Loader errors follow the same Error Boundary rules as rendering
errors, but they can surface in two slightly different ways:
</p>
<ul>
<li>
<strong>Synchronous loader throws</strong> &mdash; when React
renders that route&rsquo;s <code>{"<Outlet />"}</code>, the error is
thrown there, so the nearest Error Boundary above that outlet
catches it.
</li>
<li>
<strong>Asynchronous loader rejects</strong> &mdash; the route
component receives a Promise and the rejection surfaces when you
call <code>use(data)</code>. That rejection is also caught by the
nearest Error Boundary.
</li>
</ul>
<CodeBlock language="tsx">{`import { Suspense, use } from "react";

function UserPage({ data }: { data: Promise<User> }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserDetails data={data} />
</Suspense>
);
}

function UserDetails({ data }: { data: Promise<User> }) {
const user = use(data); // A rejected loader Promise throws here
return <h1>{user.name}</h1>;
}`}</CodeBlock>
<p>
This means you usually do not need special loader-specific error
plumbing. Put boundaries where you want recovery UI to appear, and
loader failures in that part of the route tree will bubble there.
</p>
</section>

<section>
<h3>Nested Boundaries for Finer-Grained Recovery</h3>
<p>
A root layout boundary is a good default, but you can also add
boundaries to nested layouts when a section of the app should recover
independently. For example, a dashboard layout can catch errors from
dashboard child routes while the rest of the application keeps
working.
</p>
<p>
Think of each boundary as owning the routes rendered through its{" "}
<code>{"<Outlet />"}</code>. Put the boundary as high as needed to
preserve surrounding UI, but as low as possible when you want more
specific fallback screens.
</p>
</section>

<section>
<h3>See Also: How Loaders Run</h3>
<p>
This page focuses on recovery and error propagation. For when loaders
execute, how results are cached, and when they re-run after navigation
or form submissions, see <a href="/learn/loaders">How Loaders Run</a>.
</p>
</section>
</div>
);
}
12 changes: 12 additions & 0 deletions packages/docs/src/pages/LearnIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ export function LearnIndexPage() {
</p>
</section>

<section className="learn-category">
<h2>
<a href="/learn/error-handling">Error Handling</a>
</h2>
<p>
Learn where to place Error Boundaries in your route tree, how to reset
them on navigation with <code>useLocation().entryId</code>, and how
loader errors bubble to the nearest boundary that wraps an{" "}
<code>{"<Outlet>"}</code>.
</p>
</section>

<section className="learn-category">
<h2>
<a href="/learn/transitions">Controlling Transitions</a>
Expand Down
69 changes: 7 additions & 62 deletions packages/docs/src/pages/LearnLoadersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,70 +214,15 @@ function UserDetail({ data }: { data: Promise<User> }) {
<section>
<h3>Error Handling</h3>
<p>
When a loader throws an error, the router catches it and re-throws it
during rendering of that route&rsquo;s component. This means the error
can be caught by a React{" "}
<a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">
Error Boundary
</a>{" "}
placed above the route in the component tree. For async loaders that
return a rejected promise, the error is surfaced when{" "}
<code>use(data)</code> is called, which is also caught by Error
Boundaries.
When a loader throws, the error bubbles to the nearest Error Boundary
above that route. Synchronous loader errors are re-thrown during route
rendering, while rejected loader Promises surface when{" "}
<code>use(data)</code> runs.
</p>
<p>
The recommended pattern is to place an error boundary in your{" "}
<strong>root layout route</strong>, wrapping the{" "}
<code>{"<Outlet />"}</code>. This catches errors from any loader in
the route tree while keeping the root layout (header, navigation,
etc.) intact:
</p>
<CodeBlock language="tsx">{`import { Router, route, Outlet } from "@funstack/router";
import { ErrorBoundary } from "./ErrorBoundary";

function RootLayout() {
return (
<div>
<header>My App</header>
<ErrorBoundary fallback={<div>Something went wrong.</div>}>
<Outlet />
</ErrorBoundary>
</div>
);
}

const routes = [
route({
path: "/",
component: RootLayout,
children: [
route({
path: "/",
component: HomePage,
}),
route({
path: "/users/:id",
component: UserPage,
loader: async ({ params }) => {
const res = await fetch(\`/api/users/\${params.id}\`);
if (!res.ok) throw new Error("Failed to load user");
return res.json();
},
}),
],
}),
];`}</CodeBlock>
<p>
This works for both synchronous and asynchronous loaders. For sync
loaders, the router catches the error and re-throws it during route
rendering. For async loaders, the rejected promise naturally surfaces
through <code>use()</code>. Either way, Error Boundaries catch the
error.
</p>
<p>
You can also place error boundaries at more granular levels (e.g.,
wrapping a specific route&rsquo;s <code>{"<Outlet />"}</code> or{" "}
<code>{"<Suspense>"}</code> boundary) for fine-grained error handling.
For the recommended boundary placement, how to reset it on navigation
with <code>useLocation().entryId</code>, and examples for nested
layouts, see <a href="/learn/error-handling">Error Handling</a>.
</p>
</section>

Expand Down
3 changes: 2 additions & 1 deletion packages/docs/src/pages/LearnPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const learnNavItems: NavEntry[] = [
{ path: "/learn/nested-routes", label: "Nested Routes" },
{ path: "/learn/type-safety", label: "Type Safety" },
{ path: "/learn/actions", label: "Form Actions" },
{ path: "/learn/transitions", label: "Transitions" },
{ path: "/learn/loaders", label: "How Loaders Run" },
{ path: "/learn/error-handling", label: "Error Handling" },
{ path: "/learn/transitions", label: "Transitions" },
{
label: "SSR",
items: [
Expand Down
Loading