Skip to content

raf-effect-atom and effect-atom-boundary are on npm. Both are pre-1.0, so a minor release can still break them. Install them

raf-effect-atom

effect-atom-boundary

Shared boundaries

createErrorBoundary makes a boundary for failures that belong to the app, and a hook that types the reads under it without the errors it claims.

Some failures belong to the app rather than to the component that read them. Every Effect RPC procedure fails with RpcClientError when the request doesn't reach the server or the answer doesn't decode: it's the connection failing, not one of the contract's errors, and every component handling it would repeat the same message. createErrorBoundary makes one boundary for it, returned as a component and a hook you name by destructuring:

src/boundaries.tsx
// Every procedure fails with RpcClientError when the request doesn't reach
// the server, so the app catches it once.
export const [const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary, const useAppBoundary: UseErrorBoundary<"RpcClientError">useAppBoundary] = createErrorBoundary<typeof IssuesClient, "RpcClientError", never>(options: ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>): ErrorBoundary<"RpcClientError">createErrorBoundary({
	ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "App",
	ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
	ErrorBoundaryOptions<typeof IssuesClient, "RpcClientError", never>.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "RpcClientError">catchTags: {
		RpcClientError?: ((props: CatchTagProps<RpcClientError>) => React.ReactNode) | undefinedRpcClientError: ({ reset: () => voidreset }) => (
			<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
				Can't reach the server. <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
			</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
		),
	},
});

errors names what the errors come from, for the types only: an AtomRpc client (the errors of all its procedures), an AtomHttpApi client (the errors of all its endpoints and their middleware), an atom, or an atom family. An AtomHttpApi query's transport failures aren't among them: see AtomHttpApi transport failures. name names the boundary in messages and React DevTools: "App" is <AppBoundary>. reset refreshes every atom that failed with a tag the boundary handles, so Try again loads the page again once the server is back.

Reads under the boundary

A component wraps its read in the boundary's hook. useAppBoundary(result) takes the AsyncResult a read returned, throws the failures the boundary claims to the boundary, and returns the result typed without them. Around raf-effect-atom's useAtomResult, which returns typed failures and throws defects, that's useAppBoundary(useAtomResult(issue, props.issue)):

function 
function IssueTitle(props: {
    issue: Handle<typeof issue>;
}): React.JSX.Element
IssueTitle
(
props: {
    issue: Handle<typeof issue>;
}
props
: { issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue: interface Handle<S>Handle<typeof const issue: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue> }) {
const const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result = useAppBoundary<SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>(result: SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>): AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>useAppBoundary(useAtomResult<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, handle: NoInfer<Handle<(id: number & Brand<"IssueId">) => Atom<AsyncResult.AsyncResult<Issue, unknown>>>>): SettledResult<...>useAtomResult(const issue: (id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue,
props: {
    issue: Handle<typeof issue>;
}
props
.issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue));
if (import AsyncResultAsyncResult.const isSuccess: <Issue, IssueNotFound | Unauthorized>(result: AsyncResult.AsyncResult<Issue, IssueNotFound | Unauthorized>) => result is AsyncResult.Success<Issue, IssueNotFound | Unauthorized>isSuccess(const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized> | AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result)) { return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const result: AsyncResult.Success<Issue, IssueNotFound | Unauthorized>result.Success<Issue, IssueNotFound | Unauthorized>.value: Issuevalue.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; } // useAtomResult throws defects, so a failure it returns has a typed error. const error = import OptionOption.const getOrThrow: <IssueNotFound | Unauthorized>(self: Option.Option<IssueNotFound | Unauthorized>) => IssueNotFound | UnauthorizedgetOrThrow(import AsyncResultAsyncResult.const error: <Issue, IssueNotFound | Unauthorized>(self: AsyncResult.AsyncResult<Issue, IssueNotFound | Unauthorized>) => Option.Option<IssueNotFound | Unauthorized>error(const result: AsyncResult.Failure<Issue, IssueNotFound | Unauthorized>result));
const error: IssueNotFound | Unauthorized
return import MatchMatch.
const valueTags: <IssueNotFound | Unauthorized, {
    IssueNotFound: (notFound: IssueNotFound) => React.JSX.Element;
    Unauthorized: () => React.JSX.Element;
}>(input: IssueNotFound | Unauthorized, fields: {
    IssueNotFound: (notFound: IssueNotFound) => React.JSX.Element;
    Unauthorized: () => React.JSX.Element;
}) => React.JSX.Element (+1 overload)
valueTags
(const error: IssueNotFound | Unauthorizederror, {
type IssueNotFound: (notFound: IssueNotFound) => React.JSX.ElementIssueNotFound: (notFound: IssueNotFoundnotFound) => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>There's no issue #{notFound: IssueNotFoundnotFound.id: number & Brand<"IssueId">id}.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>, type Unauthorized: () => React.JSX.ElementUnauthorized: () => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Sign in to see this issue.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>, }); }

It's a hook, called with the read it narrows, so it goes wherever the read goes. It throws when its boundary isn't mounted above the component:

function function App(): React.JSX.ElementApp() {
	return (
		<const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary>
			<
function IssueTitle(props: {
    issue: Handle<typeof issue>;
}): React.JSX.Element
IssueTitle
issue: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue={const issueHandle: Handle<(id: IssueId) => Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issueHandle} />
</const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary> ); }

A tag that none of errors' procedures can fail with is a type error:

createErrorBoundary<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>(options: ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>): ErrorBoundary<"IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError">createErrorBoundary({
	ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "App",
	ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
	ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError", never>.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "IssueNotFound" | "EmptyTitle" | "EmptyBody" | "EmptyComment" | "Unauthorized" | "RpcClientError">catchTags: {
		RpcClientEror: () => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Can't reach the server.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>,
Object literal may only specify known properties, but 'RpcClientEror' does not exist in type 'CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "IssueNotFound" | ... 4 more ... | "RpcClientError">'. Did you mean to write 'RpcClientError'?
}, });

Chained boundaries

parent puts a boundary under another one. A result passed to the child's hook throws the tags any boundary in the chain claims, each to the nearest boundary that claims it, and the claimed tags add up along the chain. Being signed out belongs to the app too, so <AuthBoundary> claims Unauthorized under <AppBoundary>, and <IssueBoundary> claims IssueNotFound under <AuthBoundary>:

src/boundaries.tsx
// Under the App boundary: every procedure can fail with Unauthorized too.
export const [const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary, const useAuthBoundary: UseErrorBoundary<"Unauthorized" | "RpcClientError">useAuthBoundary] = createErrorBoundary<typeof IssuesClient, "Unauthorized", "RpcClientError">(options: ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">): ErrorBoundary<"Unauthorized" | "RpcClientError">createErrorBoundary({
	ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "Auth",
	ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
	ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.parent?: ErrorBoundaryComponent<"RpcClientError"> | undefinedparent: const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary,
	ErrorBoundaryOptions<typeof IssuesClient, "Unauthorized", "RpcClientError">.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "Unauthorized">catchTags: {
		Unauthorized?: ((props: CatchTagProps<Unauthorized>) => React.ReactNode) | undefinedUnauthorized: ({ reset: () => voidreset }) => (
			<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
				You're signed out. <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
			</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
		),
	},
});
src/boundaries.tsx
// Under the Auth boundary: it claims IssueNotFound, and Unauthorized and
// RpcClientError still go up the chain.
export const [const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary, const useIssueBoundary: UseErrorBoundary<"IssueNotFound" | "Unauthorized" | "RpcClientError">useIssueBoundary] = createErrorBoundary<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">(options: ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">): ErrorBoundary<"IssueNotFound" | "Unauthorized" | "RpcClientError">createErrorBoundary({
	ErrorBoundaryOptions<Source, Tags extends TagsOf<ErrorsOf<Source>>, ParentTags extends string>.name: stringname: "Issue",
	ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.errors: typeof IssuesClienterrors: class IssuesClientIssuesClient,
	ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.parent?: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError"> | undefinedparent: const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary,
	ErrorBoundaryOptions<typeof IssuesClient, "IssueNotFound", "Unauthorized" | "RpcClientError">.catchTags: CatchTags<RpcClientError | IssueNotFound | EmptyTitle | EmptyBody | EmptyComment | Unauthorized, "IssueNotFound">catchTags: {
		IssueNotFound?: ((props: CatchTagProps<IssueNotFound>) => React.ReactNode) | undefinedIssueNotFound: ({ error: IssueNotFounderror }) => <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>There's no issue #{error: IssueNotFounderror.id: number & Brand<"IssueId">id}.</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>,
	},
});

useIssueBoundary(useAtomResult(issue, props.issue)) throws IssueNotFound to <IssueBoundary>, Unauthorized to <AuthBoundary> and RpcClientError to <AppBoundary>. No failure is left, so the hook types the result as a success. <IssueBoundary> throws when <AuthBoundary> isn't mounted above it.

Matching is by tag

A shared boundary catches a claimed tag from any atom read under it, not only from the results passed to its hook. reset refreshes every atom in the registry whose failure has a tag the boundary handles, not only the one whose read threw: one outage fails every atom on the page, and one reset loads them all again.

AtomHttpApi transport failures

The two clients report a failed connection differently. An AtomRpc query fails with RpcClientError, a typed failure, so <AppBoundary> claims it by tag and its reset retries it. An AtomHttpApi query turns HttpClientError (the request didn't reach the server, or the response had a status or body the client couldn't read) and a SchemaError (the answer didn't decode) into defects. Its error type lists only its endpoints' and middleware's errors, so a boundary's catchTags can't name these failures.

A defect skips the boundaries createErrorBoundary makes:

  • A read through useAtomResult throws Cause.squash(cause) to the nearest other React error boundary, such as the route's errorComponent or one the app mounts for defects.
  • A loader's ensure or fetch rejects with it, and the router renders the route's errorComponent.
  • To retry, refresh the atom before rendering it again, with atoms.fetch(source, ...args) or registry.refresh. The atom keeps the defect, and ensure returns it without a new request.

Keep these failures in that boundary. On the server, createErrorBoundary boundaries check typed failures only, and a defect reaches the browser without its _tag. A boundary that matched HttpClientError would render a fallback in the browser that the server didn't.

Reads the hook narrows

The hook takes the AsyncResult of a read that returns typed failures and suspends while a failed atom refreshes: raf-effect-atom's useAtomResult, or useAtomSuspense(atom, { includeFailure: true, suspendOnWaiting: true }). It throws a defect it's given too, to the nearest other React error boundary.

  • A read that returns the old failure while reset refreshes the atom, such as Effect Atom's useAtomValue, hands the hook the claimed failure again. The hook throws it, and the boundary keeps its handler after the refresh succeeds.
  • useAtomSuspense without includeFailure throws every failure itself, including the ones the boundaries leave, before the hook sees the result.