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

raf-effect-atom

Request waterfalls

What a request waterfall is, why starting atoms in components causes one, and how loaders, routes and backends remove them.

A request waterfall is a request that can't start until another one has finished. Each level costs a full round trip, and on a slow connection a round trip can take hundreds of milliseconds. Removing waterfalls is usually the most effective thing you can do for a page's loading time.

raf-effect-atom is built around this. Route loaders start atoms before anything renders, when everything the page needs is known from the URL, so the requests start together:

1. |-> GetIssue
1. |-> GetIssueAssignee
1. |-> ListComments

instead of one after another, as each component renders and finds out what it needs:

1. |-> GetIssue
2.   |-> GetIssueAssignee
3.     |-> ListComments

Below, two copies of a page read the same three atoms. One route's loader starts them; the other has no loader, so each component starts its own atom when it renders, with useWaterfall.

Fetch-on-render

The classic waterfall comes from starting data where it's used. Effect Atom's registry runs an atom's effect the first time anything reads it, so a component that reads an atom nothing started starts the request itself. It suspends, and its children, which read their own atoms, render only once its result is in:

// Each component builds its atom from an id with Effect Atom's own hook, so
// each read starts its own request.
function 
function IssuePage(props: {
    issueId: IssueId;
}): React.JSX.Element
IssuePage
(
props: {
    issueId: IssueId;
}
props
: { issueId: number & Brand<"IssueId">issueId: type IssueId = number & Brand<"IssueId">IssueId }) {
// Starts GetIssue, and suspends until it settles. const const result: AsyncResult.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>result =
useAtomSuspense<Issue, RpcClientError | IssueNotFound | Unauthorized, false>(atom: Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, options?: {
    readonly suspendOnWaiting?: boolean | undefined;
    readonly includeFailure?: false | undefined;
} | undefined): AsyncResult.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>
useAtomSuspense
(function issue(id: IssueId): Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue(
props: {
    issueId: IssueId;
}
props
.issueId: number & Brand<"IssueId">issueId));
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: AsyncResult.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>result.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>.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>}> {/* Renders, and starts GetIssueAssignee, only now. */} <
function Assignee(props: {
    issueId: IssueId;
}): React.JSX.Element
Assignee
issueId: number & Brand<"IssueId">issueId={
props: {
    issueId: IssueId;
}
props
.issueId: number & Brand<"IssueId">issueId} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> ); } function
function Assignee(props: {
    issueId: IssueId;
}): React.JSX.Element
Assignee
(
props: {
    issueId: IssueId;
}
props
: { issueId: number & Brand<"IssueId">issueId: type IssueId = number & Brand<"IssueId">IssueId }) {
const const result: AsyncResult.Success<User, RpcClientError | IssueNotFound | Unauthorized>result =
useAtomSuspense<User, RpcClientError | IssueNotFound | Unauthorized, false>(atom: Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, options?: {
    readonly suspendOnWaiting?: boolean | undefined;
    readonly includeFailure?: false | undefined;
} | undefined): AsyncResult.Success<User, RpcClientError | IssueNotFound | Unauthorized>
useAtomSuspense
(function assignee(issueId: IssueId): Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee(
props: {
    issueId: IssueId;
}
props
.issueId: number & Brand<"IssueId">issueId));
return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const result: AsyncResult.Success<User, RpcClientError | IssueNotFound | Unauthorized>result.Success<User, RpcClientError | IssueNotFound | Unauthorized>.value: Uservalue.name: stringname}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>; }

Suspense makes this worse, not better: a suspended component renders nothing below it, so its children can't even start their requests.

The fix is to start every atom the route reads in its loader and pass the handles it returns down to the components. A component reads a result that's already on its way, and since it takes a handle instead of an id, it can't start a request of its own:

src/routes/issues.$id.tsx
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> ), });

Both requests start the moment the navigation does. With the router's defaultPreload: "intent", they start when the user hovers the link.

On the server it matters twice. Only atoms in the registry when the loaders finish are sent to the browser, so an atom first read during render costs a round trip on the server and runs again in the browser.

In development, useAtomResult also reports a read that starts an atom nothing started, such as through a handle built by hand: see Finding waterfalls in development.

Start every atom before awaiting any

Inside a loader, order matters. An await before a prefetch turns two parallel requests back into a waterfall:

// The assignee starts only once the issue is in: two round trips.
const const issueFirst: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issueFirst = await const atoms: Loader.Loaderatoms.Loader.ensure: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const id: number & Brand<"IssueId">id);
const const assigneeLater: Handle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assigneeLater = const atoms: Loader.Loaderatoms.Loader.prefetch: <(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Handle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>prefetch(const assignee: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, const id: number & Brand<"IssueId">id);

Start what streams first, then wait for what the page can't render without:

// Both start now; the loader waits only for the issue.
const const assigneeHandle: Handle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assigneeHandle = const atoms: Loader.Loaderatoms.Loader.prefetch: <(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Handle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>prefetch(const assignee: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, const id: number & Brand<"IssueId">id);
const const issueHandle: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issueHandle = await const atoms: Loader.Loaderatoms.Loader.ensure: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const id: number & Brand<"IssueId">id);

When the loader needs several results, start them together and await them together:

// Both start now, and the loader waits for both.
const [const issueLoaded: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issueLoaded, const assigneeLoaded: LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>assigneeLoaded] = await var Promise: PromiseConstructorPromise.PromiseConstructor.all<[Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>, Promise<LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>>]>(values: [Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>, Promise<...>]): Promise<...> (+1 overload)all([
	const atoms: Loader.Loaderatoms.Loader.ensure: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const id: number & Brand<"IssueId">id),
	const atoms: Loader.Loaderatoms.Loader.ensure: <(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Promise<LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const assignee: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, const id: number & Brand<"IssueId">id),
]);

In Effect code, Effect.all runs its effects one after another unless it's given a concurrency. Typed failures stay in each result, so the loader still decides tag by tag:

// Effect.all runs its effects one after another unless it's given a
// concurrency: without it, the assignee would start after the issue settled.
const both = import EffectEffect.
const all: <readonly [Effect.Effect<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>, never, AtomRegistry>, Effect.Effect<LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>, never, AtomRegistry>], {
    ...;
}>(arg: readonly [...], options?: {
    ...;
} | undefined) => Effect.Effect<...>
all
(
const both: Effect.Effect<[LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>, LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>], never, AtomRegistry>
[import LoaderLoader.const ensure: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Effect.Effect<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>, never, AtomRegistry>ensure(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const id: number & Brand<"IssueId">id), import LoaderLoader.const ensure: <(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>(source: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>, issueId: number & Brand<"IssueId">) => Effect.Effect<LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>, never, AtomRegistry>ensure(const assignee: (issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>assignee, const id: number & Brand<"IssueId">id)], { concurrency: "unbounded"concurrency: "unbounded" }, );

Dependent atoms

An atom whose input comes from another atom's result is a waterfall by construction: the loader has to await the first before it can start the second. Fix it where the data is shaped, so every atom a route reads can start from what the URL holds:

  • Put the input in the route. If the page is about an organization, its id belongs in the path (/orgs/$orgId), not in a lookup on the current user. Links into the page come from places that already know it.
  • Have the backend resolve it. The server already knows the relations. The example contract's GetIssueAssignee takes the issue's id, not the user's, so a loader starts it beside GetIssue instead of waiting for the issue's assigneeId.

When the data really can't be reshaped, such as a third-party API, you have two options, and both pay the extra round trip:

  • In the loader: await the parent with ensure and start the child from its result, which Loader.result returns. The navigation waits for the parent, and the child streams in or waits too, as its verb says.
  • In a component: start the child with useWaterfall, an escape hatch. The rest of the page renders first.
// The input comes from the issue: the user starts a round trip later.
const const parent: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>parent = await const atoms: Loader.Loaderatoms.Loader.ensure: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, id: number & Brand<"IssueId">) => Promise<LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>>ensure(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue, const id: number & Brand<"IssueId">id);
const const found: Loader.SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>found = import LoaderLoader.const result: <(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(handle: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>) => Loader.SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>result(const parent: LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>parent);
const const userHandle: Handle<(id: UserId) => ReturnType<typeof assignee>> | undefineduserHandle = import AsyncResultAsyncResult.const isSuccess: <Issue, RpcClientError | IssueNotFound | Unauthorized>(result: AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>) => result is AsyncResult.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>isSuccess(const found: Loader.SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>found)
	? const atoms: Loader.Loaderatoms.Loader.prefetch: <(id: UserId) => ReturnType<typeof assignee>>(source: (id: UserId) => ReturnType<typeof assignee>, id: string & Brand<"UserId">) => Handle<(id: UserId) => ReturnType<typeof assignee>>prefetch(const user: (id: UserId) => ReturnType<typeof assignee>user, const found: AsyncResult.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>found.Success<Issue, RpcClientError | IssueNotFound | Unauthorized>.value: Issuevalue.assigneeId: string & Brand<"UserId">assigneeId)
	: var undefinedundefined;

Shapes that force waterfalls goes through the API and URL shapes that cause these chains: an id only another response has, a child that needs its parent's data, a shell that gates its reads, a key that isn't in the URL, one read per item, and data a background job creates. Each comes with its escape hatch and the change that removes it.

Put the state in the URL

Loaders start what the URL names. The example apps' issue list keeps its status filter and sort order in search params, so its loader starts the matching page, and a filtered list survives a reload or a shared link.

Search as you type

A search box's text is URL state too. Keep it in a search param, start the search in the loader, and have the input replace the param:

src/routes/search.tsx
export const 
const Route: Route<Register, RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", "/search", "/search", "/search", StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, ResolveParams<"/search">, AnyContext, AnyContext, ... 7 more ..., undefined>
Route
=
createFileRoute<"/search", RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", "/search", "/search">(path?: "/search" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<"/search", TParams> & FilebaseRouteOptionsInterface<...> & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
("/search")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { atoms: Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 12 more ..., undefined>.validateSearch?: Constrain<StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, AnyValidator, DefaultValidator>
validateSearch
: import SchemaSchema.
function toStandardSchemaV1<Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>>(self: Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, options?: {
    readonly leafHook?: LeafHook | undefined;
    readonly checkHook?: CheckHook | undefined;
    readonly parseOptions?: ParseOptions | undefined;
}): StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<...>
toStandardSchemaV1
(
import SchemaSchema.
function Struct<{
    readonly q: Schema.optional<Schema.String>;
}>(fields: {
    readonly q: Schema.optional<Schema.String>;
}): Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>
Struct
({ q: Schema.optional<Schema.String>q: import SchemaSchema.
const optional: optionalLambda
<Schema.String>(self: Schema.String) => Schema.optional<Schema.String>
optional
(import SchemaSchema.const String: Schema.StringString) }),
),
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { atoms: Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 12 more ..., undefined>.loaderDeps?: ((opts: FullSearchSchemaOption<RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>>) => {
    q: string | undefined;
}) | undefined
loaderDeps
: ({
search: {
    readonly q?: string | undefined;
}
search
}) => ({ q: string | undefinedq:
search: {
    readonly q?: string | undefined;
}
search
.q?: string | undefinedq }),
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { atoms: Loader; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<({ context, deps }: LoaderFnContext<Register, RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", ResolveParams<"/search">, {
    q: string | undefined;
}, AnyContext, AnyContext, AnyContext, unknown, undefined>) => {
    results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefined;
}, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: ({
context: {
    atoms: Loader;
}
context
,
deps: {
    q: string | undefined;
}
deps
}) => ({
// Starts the search the URL names, if it names one. results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefinedresults:
deps: {
    q: string | undefined;
}
deps
.q: string | undefinedq === var undefinedundefined
? var undefinedundefined :
context: {
    atoms: Loader;
}
context
.atoms: Loaderatoms.Loader.prefetch: <(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>(source: (query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>, query: string) => Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>prefetch(const searchIssues: (query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>searchIssues,
deps: {
    q: string | undefined;
}
deps
.q: stringq),
}), UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function SearchPage(): React.JSX.ElementSearchPage, }); function function SearchPage(): React.JSX.ElementSearchPage() { const
const search: {
    readonly q?: string | undefined;
}
search
=
const Route: Route<Register, RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", "/search", "/search", "/search", StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, ResolveParams<"/search">, AnyContext, AnyContext, ... 7 more ..., undefined>
Route
.
RouteExtensions<"/search", "/search">.useSearch: <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, true>(opts?: (UseSearchBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    atoms: Loader;
}, AnyContext, ... 7 more ..., undefined>, "never", false, RouterHistory, Record<...>>, ... 4 more ..., boolean> & OptionalStructuralSharing<...>) | undefined) => {
    ...;
}
useSearch
();
const const navigate: UseNavigateResult<"/search">navigate =
const Route: Route<Register, RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", "/search", "/search", "/search", StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, ResolveParams<"/search">, AnyContext, AnyContext, ... 7 more ..., undefined>
Route
.RouteExtensions<"/search", "/search">.useNavigate: () => UseNavigateResult<"/search">useNavigate();
const
const data: {
    results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefined;
}
data
=
const Route: Route<Register, RootRoute<Register, undefined, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/search", "/search", "/search", "/search", StandardSchemaV1<{
    readonly q?: string | undefined;
}, {
    readonly q?: string | undefined;
}> & Schema.Struct<{
    readonly q: Schema.optional<Schema.String>;
}>, ResolveParams<"/search">, AnyContext, AnyContext, ... 7 more ..., undefined>
Route
.
RouteExtensions<"/search", "/search">.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<...>>, "/search", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
    ...;
}
useLoaderData
();
return ( <React.JSX.IntrinsicElements.search: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>search> <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.AriaAttributes["aria-label"]?: string | undefinedaria-label="Search issues" React.HTMLAttributes<HTMLInputElement>.defaultValue?: string | number | readonly string[] | undefineddefaultValue={
const search: {
    readonly q?: string | undefined;
}
search
.q?: string | undefinedq}
React.InputHTMLAttributes<HTMLInputElement>.onChange?: React.ChangeEventHandler<HTMLInputElement, HTMLInputElement> | undefinedonChange={(event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>event) => { void
const navigate: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, string | undefined, "/search", "/search", "">({ from, ...rest }: NavigateOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    atoms: Loader;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/search", string | undefined, "/search", "">) => Promise<void>
navigate
({
NavigateOptionProps.replace?: boolean | undefinedreplace: true,
MakeOptionalSearchParams<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { atoms: Loader; }, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/search", string | undefined>.search?: true | {
    readonly q?: string | undefined;
} | ParamsReducerFn<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    atoms: Loader;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, "SEARCH", "/search", string | undefined> | undefined
search
: { q?: string | undefinedq: event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>event.React.ChangeEvent<HTMLInputElement, HTMLInputElement>.target: EventTarget & HTMLInputElementtarget.HTMLInputElement.value: stringvalue || var undefinedundefined },
}); }} React.InputHTMLAttributes<HTMLInputElement>.type?: React.HTMLInputTypeAttribute | undefinedtype="search" /> {
const data: {
    results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefined;
}
data
.results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefinedresults === var undefinedundefined ? null : (
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Searching…</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>}> <
function SearchResults(props: {
    results: Handle<typeof searchIssues>;
}): React.JSX.Element
SearchResults
results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>results={
const data: {
    results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>> | undefined;
}
data
.results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>results} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> )} </React.JSX.IntrinsicElements.search: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>search> ); } function
function SearchResults(props: {
    results: Handle<typeof searchIssues>;
}): React.JSX.Element
SearchResults
(
props: {
    results: Handle<typeof searchIssues>;
}
props
: { results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>results: interface Handle<S>Handle<typeof const searchIssues: (query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>searchIssues> }) {
// The auth and app boundaries claim Unauthorized and RpcClientError. const result = useAuthBoundary<SettledResult<readonly Issue[], RpcClientError | Unauthorized>>(result: SettledResult<readonly Issue[], RpcClientError | Unauthorized>): Success<readonly Issue[], never>useAuthBoundary(
const result: Success<readonly Issue[], never>
useAtomResult<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>(source: (query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>, handle: NoInfer<Handle<(query: string) => Atom<AsyncResult<readonly Issue[], unknown>>>>): SettledResult<readonly Issue[], RpcClientError | Unauthorized>useAtomResult(const searchIssues: (query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>searchIssues,
props: {
    results: Handle<typeof searchIssues>;
}
props
.results: Handle<(query: string) => Atom<AsyncResult<readonly Issue[], RpcClientError | Unauthorized>>>results),
); return ( <React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>ul> {const result: Success<readonly Issue[], never>result.Success<readonly Issue[], never>.value: readonly Issue[]value.ReadonlyArray<Issue>.map<React.JSX.Element>(callbackfn: (value: Issue, index: number, array: readonly Issue[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((found: Issuefound) => ( <React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>li React.Attributes.key?: React.Key | null | undefinedkey={found: Issuefound.id: number & Brand<"IssueId">id}>{found: Issuefound.title: stringtitle}</React.JSX.IntrinsicElements.li: React.DetailedHTMLProps<React.LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>li> ))} </React.JSX.IntrinsicElements.ul: React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>ul> ); }

Each keystroke navigates, and the loader starts the search for the text the URL holds. To search less often, debounce the navigate call.

Inputs only the browser has

Some reads need what only the browser has: a one-time token in the URL's fragment, a credential in sessionStorage, or a browser API such as navigator.permissions. A loader on the server can't start them, and starting them from a useEffect after hydration waits for that render.

prefetchInBrowser(source, ...args) starts them from the same loader as the rest of the page. In the browser it's prefetch. On the server it starts nothing and returns a handle marked to start in the browser: the server renders the page around the read and the nearest Suspense boundary's fallback, and the browser's read starts the atom as it hydrates that boundary:

// Only the browser can ask: the atom runs there, so it needs no
// serialization key.
const const notifications: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>notifications = import AtomAtom.
const make: <PermissionStatus, never>(effect: Effect.Effect<PermissionStatus, never, Scope | AtomRegistry>, options?: {
    readonly initialValue?: PermissionStatus | undefined;
    readonly uninterruptible?: boolean | undefined;
} | undefined) => Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>> (+5 overloads)
make
(
import EffectEffect.const promise: <PermissionStatus>(evaluate: (signal: AbortSignal) => PromiseLike<PermissionStatus>) => Effect.Effect<PermissionStatus, never, never>promise(() => var navigator: Navigatornavigator.Navigator.permissions: Permissionspermissions.Permissions.query(permissionDesc: PermissionDescriptor): Promise<PermissionStatus>query({ PermissionDescriptor.name: PermissionNamename: "notifications" })), ).Pipeable.pipe<Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>>, Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>(this: Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>>, ab: (_: Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>>) => Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>): Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>> (+21 overloads)pipe(import AtomAtom.const map: <Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>>, AsyncResult.AsyncResult<PermissionState, never>>(f: (_: AsyncResult.AsyncResult<PermissionStatus, never>) => AsyncResult.AsyncResult<PermissionState, never>) => (self: Atom.Atom<AsyncResult.AsyncResult<PermissionStatus, never>>) => Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>> (+1 overload)map(import AsyncResultAsyncResult.const map: <PermissionStatus, PermissionState>(f: (a: PermissionStatus) => PermissionState) => <E>(self: AsyncResult.AsyncResult<PermissionStatus, E>) => AsyncResult.AsyncResult<PermissionState, E> (+1 overload)map((status: PermissionStatusstatus) => status: PermissionStatusstatus.PermissionStatus.state: PermissionStatestate))); const
const appRoot: RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
appRoot
=
createRootRouteWithContext<{
    readonly atoms: Loader.Loader;
}>(): <TRegister, TRouteContextFn, TBeforeLoadFn, TSearchValidator, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>(options?: RootRouteOptions<TRegister, TSearchValidator, {
    readonly atoms: Loader.Loader;
}, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, undefined> | undefined) => RootRoute<...>
createRootRouteWithContext
<{
readonly atoms: Loader.Loaderatoms: import LoaderLoader.Loader; }>()(); const
const settingsRoute: Route<unknown, RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", string, "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, ({ context }: LoaderFnContext<unknown, RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 7 more ..., undefined>) => {
    ...;
}, ... 4 more ..., undefined>
settingsRoute
=
createRoute<unknown, RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", string, "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, {}, ({ context }: LoaderFnContext<unknown, RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, ... 5 more ..., undefined>) => {
    ...;
}, unknown, unknown, unknown>(options: RouteOptions<...>): Route<...>
createRoute
({
getParentRoute: () => RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
getParentRoute
: () =>
const appRoot: RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
appRoot
,
path: "/settings"path: "/settings",
loader: ({ context }: LoaderFnContext<unknown, RootRoute<Register, undefined, {
    readonly atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 7 more ..., undefined>) => {
    ...;
}
loader
: ({
context: {
    readonly atoms: Loader.Loader;
}
context
}) => ({
// The server renders the page without it; the browser's read starts it. notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications:
context: {
    readonly atoms: Loader.Loader;
}
context
.atoms: Loader.Loaderatoms.Loader.prefetchInBrowser: <Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>(source: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>) => Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>prefetchInBrowser(const notifications: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>notifications),
}), }); // The page renders on the server; the boundary waits for the browser. function
function SettingsPage(props: {
    notifications: Handle<typeof notifications>;
}): React.JSX.Element
SettingsPage
(
props: {
    notifications: Handle<typeof notifications>;
}
props
: { notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications: interface Handle<S>Handle<typeof const notifications: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>notifications> }) {
return ( <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Checking notifications…</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>}> <
function NotificationSetting(props: {
    notifications: Handle<typeof notifications>;
}): React.JSX.Element
NotificationSetting
notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications={
props: {
    notifications: Handle<typeof notifications>;
}
props
.notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> ); } function
function NotificationSetting(props: {
    notifications: Handle<typeof notifications>;
}): React.JSX.Element
NotificationSetting
(
props: {
    notifications: Handle<typeof notifications>;
}
props
: {
notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications: interface Handle<S>Handle<typeof const notifications: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>notifications>; }) { const const result: AsyncResult.Success<PermissionState, never>result = useAtomResult<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>(source: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>, handle: NoInfer<Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, unknown>>>>): AsyncResult.Success<PermissionState, never>useAtomResult(const notifications: Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>notifications,
props: {
    notifications: Handle<typeof notifications>;
}
props
.notifications: Handle<Atom.Atom<AsyncResult.AsyncResult<PermissionState, never>>>notifications);
return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Notifications: {const result: AsyncResult.Success<PermissionState, never>result.Success<PermissionState, never>.value: PermissionStatevalue}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>; }
  • The atom reads what only the browser has inside its effect, so it needs no serialization key: the server never starts it, and the arguments the loader passes are what the URL holds.
  • On client navigations the loader runs in the browser, so the request starts with the page's other atoms. After a server render it starts once the boundary hydrates, the earliest the browser can, and the waterfall check doesn't report it.
  • Read it under a Suspense boundary. The server's read throws, so React renders the boundary's fallback and the browser renders the boundary; with no boundary around it, the server can't render the page. React reports the throw to the server's onError and the browser's onRecoverableError, as for any boundary the server leaves to the browser. A component that renders its own loading state and failures reads the handle with useHandleValue instead: it returns Initial on the server, so nothing throws and the page keeps the component's own markup around the read.
  • It also keeps out of the HTML a value the server could fetch but shouldn't send, such as a signing secret. The server never starts the atom, so it needs no placeholder there: give the server and the browser the same atom.
  • On Next.js, a server component calls prefetchInBrowser from raf-effect-atom/rsc and passes the handle to a client component. On React Router, loaders call it on atoms, like the other verbs.

Escape hatches

What's left is an input no loader can know: a choice a click or a submit makes that doesn't belong in the URL, or an id only a render finds.

Event handlers

When a click or a submit picks the data, the handler starts the atom with Loader.make(registry).prefetch, which gives it the same verbs a loader has, and keeps the handle in state for the component that reads it. The request starts with the event, before the render that shows it, so it isn't a waterfall.

Render-time inputs: useWaterfall

When an input exists only in render, such as an id a component reads from another atom's result, no loader or handler can start the atom. useWaterfall(source, ...args) starts it during render, like prefetch, and returns its handle, so the read looks like one fed by a loader:

// The user's id is in the issue, so only a render that has the issue knows it.
function 
function IssueAssignee(props: {
    issue: Handle<typeof issue>;
}): React.JSX.Element
IssueAssignee
(
props: {
    issue: Handle<typeof issue>;
}
props
: { issue: Handle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue: interface Handle<S>Handle<typeof const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue> }) {
const const result: AsyncResult.Success<Issue, never>result = useIssueBoundary<Loader.SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>(result: Loader.SettledResult<Issue, RpcClientError | IssueNotFound | Unauthorized>): AsyncResult.Success<Issue, never>useIssueBoundary(useAtomResult<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>(source: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>, handle: NoInfer<Handle<(id: number & Brand<"IssueId">) => Atom.Atom<AsyncResult.AsyncResult<Issue, unknown>>>>): Loader.SettledResult<...>useAtomResult(const issue: (id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>issue,
props: {
    issue: Handle<typeof issue>;
}
props
.issue: Handle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>issue));
return ( <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 user…</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>}> <
function UserName(props: {
    id: UserId;
}): React.JSX.Element
UserName
id: string & Brand<"UserId">id={const result: AsyncResult.Success<Issue, never>result.Success<Issue, never>.value: Issuevalue.assigneeId: string & Brand<"UserId">assigneeId} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> ); } function
function UserName(props: {
    id: UserId;
}): React.JSX.Element
UserName
(
props: {
    id: UserId;
}
props
: { id: string & Brand<"UserId">id: type UserId = string & Brand<"UserId">UserId }) {
// The issue, auth and app boundaries claim IssueNotFound, Unauthorized and // RpcClientError. const result = useIssueBoundary<Loader.SettledResult<User, RpcClientError | IssueNotFound | Unauthorized>>(result: Loader.SettledResult<User, RpcClientError | IssueNotFound | Unauthorized>): AsyncResult.Success<User, never>useIssueBoundary(
const result: AsyncResult.Success<User, never>
useAtomResult<(id: UserId) => ReturnType<typeof assignee>>(source: (id: UserId) => ReturnType<typeof assignee>, handle: NoInfer<Handle<(id: string & Brand<"UserId">) => Atom.Atom<AsyncResult.AsyncResult<User, unknown>>>>): Loader.SettledResult<User, RpcClientError | IssueNotFound | Unauthorized>useAtomResult(const user: (id: UserId) => ReturnType<typeof assignee>user, useWaterfall<(id: UserId) => ReturnType<typeof assignee>>(source: (id: UserId) => ReturnType<typeof assignee>, id: string & Brand<"UserId">): Handle<(id: UserId) => ReturnType<typeof assignee>>useWaterfall(const user: (id: UserId) => ReturnType<typeof assignee>user,
props: {
    id: UserId;
}
props
.id: string & Brand<"UserId">id)),
); return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>{const result: AsyncResult.Success<User, never>result.Success<User, never>.value: Uservalue.name: stringname}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>; }

It starts the atom only if nothing has, so re-renders don't request it again, and it waits for a result the server is still streaming. Pass it the source a loader would, not an atom derived from it: a derived atom with no serialization key can't be sent from the server.

The name is the warning: each call is a round trip after the render that found the input. Search a codebase for useWaterfall and you have a list of its waterfalls. It isn't for typed input, where it would start a request for every value the input passes through: put that in the URL.

Finding waterfalls in development

In development, useAtomResult checks every read: when the atom it reads has no node in the registry, no hydrated value and no result streaming in, the read is about to start it in render. WaterfallDetection in the root layout sets what it does then:

src/routes/__root.tsx
export const 
const Route: RootRoute<Register, undefined, {
    atoms: Loader.Loader;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>
Route
=
createRootRouteWithContext<{
    atoms: Loader.Loader;
}>(): <TRegister, TRouteContextFn, TBeforeLoadFn, TSearchValidator, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>(options?: RootRouteOptions<TRegister, TSearchValidator, {
    atoms: Loader.Loader;
}, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, undefined> | undefined) => RootRoute<...>
createRootRouteWithContext
<{ atoms: Loader.Loaderatoms: import LoaderLoader.Loader }>()({
component?: RouteComponent | undefinedcomponent: () => ( // In development, a component that starts an atom no loader started throws. <
const WaterfallDetection: (props: {
    readonly children?: React.ReactNode | undefined;
    readonly mode: WaterfallMode;
}) => React.ReactNode
WaterfallDetection
mode: Modemode="throw">
<const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary> <const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary> <const Outlet: React.MemoExoticComponent<() => import("react").JSX.Element | null>Outlet /> </const AuthBoundary: ErrorBoundaryComponent<"Unauthorized" | "RpcClientError">AuthBoundary> </const AppBoundary: ErrorBoundaryComponent<"RpcClientError">AppBoundary> </
const WaterfallDetection: (props: {
    readonly children?: React.ReactNode | undefined;
    readonly mode: WaterfallMode;
}) => React.ReactNode
WaterfallDetection
>
), });
modeWhen a read starts an atom nothing started
"warn"console.warn once per atom, naming it. The default.
"throw"Throws to the nearest error boundary, so the page can't be missed.
"off"Nothing.

The report names the atom and how to fix it:

raf-effect-atom: rendering read AtomRpc:GetIssueAssignee:1 before anything else had started it, so its request waits for this render: a request waterfall. Start it before render and pass its handle down, from the route's loader or the server component (atoms.prefetch(source, ...args) or atoms.ensure). If the state that picks it, such as a page, a filter or an open dialog, isn't in the URL, put it there so the loader can. As escape hatches for state that can't be in the URL: start it in the event handler that changes the state (Loader.make(registry).prefetch), or, if its input only exists in render, read it with useAtomResult(source, useWaterfall(source, ...args)). https://raf-effect-atom.katt.dev/docs/request-waterfalls

Atoms a loader, a server component, an event handler or useWaterfall started aren't reported, and neither is a derived atom over one of those. The example apps use "throw", so a component that forgets its loader breaks the page in development instead of shipping a waterfall.

Production builds don't check. The check sits behind process.env.NODE_ENV !== "production", which bundlers replace at build time, so it's left out of the bundle.

Code splitting

Lazy route components are another waterfall: the code has to download before it renders and finds out what to fetch. A route's loader is part of the route definition, not of the lazy component, so the router runs it while the component's code downloads.

Summary

  • Start atoms in loaders and server components, never in render, and pass their handles to the components that read them.
  • Put the state that picks data in the URL: search params, a search box's text included, and routes for dialogs.
  • Start every atom before awaiting any, and give Effect.all a concurrency.
  • Shape data so a route's inputs come from the URL, not from other atoms: see Shapes that force waterfalls.
  • Start reads that need what only the browser has, such as a token in the URL's fragment, with prefetchInBrowser in the loader, and read them under Suspense.
  • Start an atom a click or a submit picks in its handler, and one whose input only a render finds with useWaterfall, a known cost.
  • Keep <WaterfallDetection mode="throw"> in the root layout, so development finds the rest.