# Loader failures

> A typed failure a loader hit on the server crosses to the browser as the same tagged error, where a boundary catches it or the loader turns it into a redirect.

`ensure` and `fetch` don't reject on a typed failure: the handle they return reads as the failed result. A failure from the server reaches the browser as the same tagged error, decoded by the procedure's Schema, so there are two places to handle it.

## In a boundary

Let the route render and the read throw the failure to the boundary that claims it. The issue route passes its read to `useIssueBoundary`, so `IssueNotFound` goes to `<IssueBoundary>`, `Unauthorized` to `<AuthBoundary>` and `RpcClientError` to `<AppBoundary>`:

```tsx title="src/boundaries.tsx"
// Under the Auth boundary: it claims IssueNotFound, and Unauthorized and
// RpcClientError still go up the chain.
export const [IssueBoundary, useIssueBoundary] = createErrorBoundary({
	name: "Issue",
	errors: IssuesClient,
	parent: AuthBoundary,
	catchTags: {
		IssueNotFound: ({ error }) => <p>There's no issue #{error.id}.</p>,
	},
});
```

```tsx title="src/routes/issues.$id.tsx"
export const Route = createFileRoute("/issues/$id")({
	params: {
		parse: (params) => Schema.decodeUnknownSync(IssueParams)(params),
		stringify: Schema.encodeSync(IssueParams),
	},
	loader: async ({ context, params }) => ({
		// Starts the assignee; it streams in while the page renders.
		assignee: context.atoms.prefetch(assignee, params.id),
		// Waits for the issue, so the page renders with it.
		issue: await context.atoms.ensure(issue, params.id),
	}),
	component: () => (
		<IssueBoundary>
			<IssuePage />
		</IssueBoundary>
	),
});

function IssuePage() {
	const data = Route.useLoaderData();
	// IssueNotFound, Unauthorized and RpcClientError go to their boundaries,
	// so no failure is left.
	const result = useIssueBoundary(useAtomResult(issue, data.issue));
	// const result: Success<Issue, never>
	return (
		<article>
			<h1>{result.value.title}</h1>
			<Suspense fallback={<p>Loading the assignee…</p>}>
				<Assignee assignee={data.assignee} />
			</Suspense>
		</article>
	);
}

// Takes the handle the loader returned, not an id: it reads a request that
// already started, and can't start its own.
function Assignee(props: { assignee: Handle<typeof assignee> }) {
	const result = useIssueBoundary(useAtomResult(assignee, props.assignee));
	return <p>{result.value.name}</p>;
}
```

The server renders the boundary's handler and sends the failed result with the page. The browser keeps the handler's HTML while it hydrates, then the read throws the hydrated failure and the boundary catches it, all without a request. `reset` refreshes the atom in the browser.

### During server rendering

React runs no error boundaries on the server: a read that throws there fails the response, or leaves that part of the page to the browser. So a boundary checks the registry before it renders its children, and renders its handler in their place for a failure it catches that has settled.

- `AtomErrorBoundary` reads its own atom, from the registry or from the state the page hydrated, under every framework.
- A shared boundary looks through the atoms that already ran in the registry it reads: the loaders' on TanStack Start, and on React Router, the request's under `HydrationBoundary`. A shared boundary above a route's `HydrationBoundary`, or in a Next.js client component, doesn't see them.
- A failure that settles after the boundary rendered, such as one a loader `prefetch`ed, streams to the browser instead.

A failure the boundary doesn't see is thrown on the server, and the browser renders the handler. On React Router, a throw before the page's shell is ready makes the response a 500.

## In the loader

When a failure should be the router's not-found page or a redirect, the loader reads the result's error:

```tsx
export const Route = createFileRoute("/issues/$id")({
	params: {
		parse: (params) => Schema.decodeUnknownSync(IssueParams)(params),
		stringify: Schema.encodeSync(IssueParams),
	},
	loader: async ({ context, params }) => {
		const handle = await context.atoms.ensure(issue, params.id);
		const error = AsyncResult.error(Loader.result(handle));
		// const error: Option.Option<RpcClientError | IssueNotFound | Unauthorized>
		// Only IssueNotFound becomes a 404. Unauthorized and RpcClientError
		// stay in the result, for the page's boundaries.
		if (
			Option.isSome(error) &&
			Predicate.isTagged(error.value, "IssueNotFound")
		) {
			throw notFound();
		}
		return { issue: handle };
	},
});
```

The typed failure stays a value until the loader decides what it means. Only a defect or an interruption rejects, and goes to the router's error component.

## Reads behind a check

The server sends every atom the loaders started, so data the page should carry only once a check passes, such as an access check, starts after the check rather than with it. The loader `ensure`s the check and starts the reads it guards only when it succeeded:

```tsx
export const GuardedRoute = createFileRoute("/issues/$id")({
	params: {
		parse: (params) => Schema.decodeUnknownSync(IssueParams)(params),
		stringify: Schema.encodeSync(IssueParams),
	},
	loader: async ({ context, params }) => {
		// The check: GetIssue fails with Unauthorized for a signed-out user.
		const issueHandle = await context.atoms.ensure(issue, params.id);
		if (AsyncResult.isFailure(Loader.result(issueHandle))) {
			// The assignee never starts, so the page carries none of it.
			return { assignee: undefined, issue: issueHandle };
		}
		return {
			assignee: context.atoms.prefetch(assignee, params.id),
			issue: issueHandle,
		};
	},
});
```

A failed check sends none of the guarded atoms, even ones that would have succeeded. The reads wait for the check, one round trip, which on the server is usually an in-process call, and still run alongside each other. The server should still authorize each read on its own: the check decides what the page shows, not what a request may fetch. Once every endpoint does, the loader can start the reads with the check instead of after it: see [A shell that gates the reads inside it](https://raf-effect-atom.katt.dev/docs/data-shapes.md#a-shell-that-gates-the-reads-inside-it).
