Getting started
Overview
Two thin layers on Effect Atom. Start atoms from route loaders and stream them to the browser, and catch their typed failures by tag in error boundaries.
This site documents two packages that add what Effect Atom leaves to the app when its atoms call an Effect RPC backend from a server-rendered React app:
effect-atom-boundary: typed error boundaries. A boundary catches an atom's failures by_tag, with a handler per tag typed by the error with that tag. It works with plain@effect/atom-react.raf-effect-atom: render-as-you-fetch. Route loaders start atoms withprefetch,ensureorfetch, the server streams their results into the browser's registry, typed failures included, and components read them withuseAtomResult.
Each works without the other. Together, a failure that happened on the server is caught in the browser by the boundary that claims its tag.
What a route looks like
export const const Route: Route<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/issues/$id", "/issues/$id", "/issues/$id", "/issues/$id", undefined, {
readonly id: number & Brand<"IssueId">;
}, AnyContext, AnyContext, AnyContext, {}, ({ context, params }: LoaderFnContext<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route = createFileRoute<"/issues/$id", RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/issues/$id", "/issues/$id", "/issues/$id">(path?: "/issues/$id" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute("/issues/$id")({
params?: {
parse?: ParseParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined;
priority?: number;
stringify?: StringifyParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined;
} | undefined
params: {
parse?: ParseParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined
parse: (params: {
id: string;
}
params) => import SchemaSchema.function decodeUnknownSync<Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>>(schema: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>, options?: ParseOptions): (input: unknown, options?: ParseOptions) => {
readonly id: number & Brand<"IssueId">;
}
decodeUnknownSync(const IssueParams: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>
IssueParams)(params: {
id: string;
}
params),
stringify?: StringifyParamsFn<"/issues/$id", {
readonly id: number & Brand<"IssueId">;
}> | undefined
stringify: import SchemaSchema.const encodeSync: <Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>>(schema: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>, options?: ParseOptions) => (input: {
readonly id: number & Brand<"IssueId">;
}, options?: ParseOptions) => {
readonly id: string;
}
encodeSync(const IssueParams: Schema.Struct<{
readonly id: Schema.compose<Schema.brand<Schema.Int, "IssueId">, Schema.NumberFromString>;
}>
IssueParams),
},
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { atoms: Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<({ context, params }: LoaderFnContext<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async ({ context: {
atoms: Loader;
}
context, params: {
readonly id: number & Brand<"IssueId">;
}
params }) => ({
// Starts the assignee; it streams in while the page renders.
assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee: context: {
atoms: Loader;
}
context.atoms: Loaderatoms.Loader.prefetch: <(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>prefetch(const assignee: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, params: {
readonly id: number & Brand<"IssueId">;
}
params.id: number & Brand<"IssueId">id),
// Waits for the issue, so the page renders with it.
issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue: await context: {
atoms: Loader;
}
context.atoms: Loaderatoms.Loader.ensure: <(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, params: {
readonly id: number & Brand<"IssueId">;
}
params.id: number & Brand<"IssueId">id),
}),
UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: () => (
<const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary>
<function IssuePage(): React.JSX.ElementIssuePage />
</const IssueBoundary: ErrorBoundaryComponent<"IssueNotFound" | "Unauthorized" | "RpcClientError">IssueBoundary>
),
});
function function IssuePage(): React.JSX.ElementIssuePage() {
const const data: {
assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>;
issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>;
}
data = const Route: Route<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/issues/$id", "/issues/$id", "/issues/$id", "/issues/$id", undefined, {
readonly id: number & Brand<"IssueId">;
}, AnyContext, AnyContext, AnyContext, {}, ({ context, params }: LoaderFnContext<Register, RootRoute<Register, undefined, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route.RouteExtensions<"/issues/$id", "/issues/$id">.useLoaderData: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, unknown, boolean>(opts?: (UseLoaderDataBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/issues/$id", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useLoaderData();
// IssueNotFound, Unauthorized and RpcClientError go to their boundaries,
// so no failure is left.
const result = useIssueBoundary<SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>(result: SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>): Success<Issue, never>useIssueBoundary(useAtomResult<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, handle: NoInfer<Handle<(id: number & Brand<"IssueId">) => Atom<AsyncResult<Issue, unknown>>>>): SettledResult<Issue, RpcClientError | ... 1 more ... | Unauthorized>useAtomResult(const issue: (id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const data: {
assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>;
issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>;
}
data.issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue)); return (
<React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
<React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const result: Success<Issue, never>result.Success<Issue, never>.value: Issuevalue.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Loading the assignee…</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>}>
<function Assignee(props: {
assignee: Handle<typeof assignee>;
}): React.JSX.Element
Assignee assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee={const data: {
assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>;
issue: LoadedHandle<(id: IssueId) => Atom<AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>;
}
data.assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense>
</React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
);
}
// Takes the handle the loader returned, not an id: it reads a request that
// already started, and can't start its own.
function function Assignee(props: {
assignee: Handle<typeof assignee>;
}): React.JSX.Element
Assignee(props: {
assignee: Handle<typeof assignee>;
}
props: { assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee: interface Handle<S>Handle<typeof const assignee: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee> }) {
const const result: Success<User, never>result = useIssueBoundary<SettledResult<User, RpcClientError | IssueNotFound | Unauthorized>>(result: SettledResult<User, RpcClientError | IssueNotFound | Unauthorized>): Success<User, never>useIssueBoundary(useAtomResult<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, handle: NoInfer<Handle<(issueId: number & Brand<"IssueId">) => Atom<AsyncResult<User, unknown>>>>): SettledResult<...>useAtomResult(const assignee: (issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, props: {
assignee: Handle<typeof assignee>;
}
props.assignee: Handle<(issueId: IssueId) => Atom<AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assignee));
return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const result: Success<User, never>result.Success<User, never>.value: Uservalue.name: stringname}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>;
}The loader starts both atoms before anything renders: it waits for the issue and lets the assignee stream in. The component passes the issue's result to useIssueBoundary, so IssueNotFound renders the issue boundary's message, Unauthorized the app's sign-in boundary, and a request that doesn't reach the server the app boundary's. No failure is left, so the component renders the issue.
Built for coding agents
Coding agents write what compiles and runs, so the setup makes the usual mistakes fail loudly. With <WaterfallDetection mode="throw">, a component that reads an atom no loader started throws in development, naming the atom and the fix. A boundary's catchTags keys come from the atom's error type, so a misspelled tag doesn't compile, and a failure the boundaries don't claim stays in the read's type, so a Match.valueTags over it that forgets a tag doesn't compile either. The agent is corrected by the tools, and llms.txt starts with the rules on For coding agents.
What Effect Atom already does
The registry is the cache: it dedupes, keeps atoms while they're mounted, evicts idle ones, refreshes on demand, and hydrates serializable atoms. AtomRpc turns an RPC group into query and mutation atoms whose error types are the procedures' errors. These packages don't replace any of that. They add the two things Atom leaves out: starting atoms where React recommends, before render, and catching failures by type.
Where to go next
- Installation and the quick start.
- Error boundaries, for
effect-atom-boundaryon its own. - Loader verbs, for
raf-effect-atom. - Loader failures, for both together.
- For coding agents, the patterns an agent should write and the ones to avoid.