С любовью ко всем разработчикам на свете ❤️
A point with pending data shows a loading component; a point whose data
throws shows an error component. You declare both with .loading and
.error — usually once near the root, overriding per point when you
need to. Point0 picks the nearest one up the chain, so a page never has to write
a loading branch itself.
export const root = Point0.lets
.root()
.loading(() => <Spinner size="3xl" className="m-auto" />)
.error(({ error }) => <ErrorScreen error={error} />)
.root()Every page, layout, and component below this root now has a loading and an error screen — without repeating them.
A loading component receives only its render position; an error component also receives the error:
.loading(({ type }) => <Spinner />)
// type is 'page' | 'component' | 'layout' — where this loading renders
.error(({ type, error }) => <ErrorScreen error={error} />)
// error is an ErrorPoint0 (or any error class you configured), never a raw Errortype lets one component branch on where it sits — a full-page spinner for
'page', an inline one for 'component'. error is normalized through the
configured error class first (ErrorPoint0.from(...) by default), so
error.message is typed and you can read error.status, error.code, and the
rest. Swap ErrorPoint0 for your own error class — any class with a compatible
(same-or-wider) structure works. See Error handling.
If you set nothing, Point0 falls back to its defaults: Loading... text, and a
<pre> dump of the error. The default error component never renders the stack
in production — only in dev — to avoid baking a server stack trace into the
SSR HTML.
.loading / .error and their per-variant forms (.pageLoading,
.layoutError, …) are render-side stage-methods: under
ssr: false, or after a .clientOnly() earlier in the chain (which makes the
rest of the chain behave as ssr: false), their bodies are stripped from the
server bundle along with the other render methods (.page / .layout / .with
/ …). They never carry server-only secrets, so gate auth in a .with,
not here (see below).
Strip category — server-ssr-and-client. Applies to
.loadingand.errorand every per-variant form (.pageLoading/.pageError,.layoutLoading/.layoutError,.componentLoading/.componentError): kept on the client always; on the server only when SSR is enabled.
.loading and .error live on the points that render — root,
base, plugin, page, layout,
component, and provider. They are not available on
a query, infinite-query, mutation, or
action: a data-only point has no UI, so you declare its loading and
error on whatever page, layout, or component consumes it.
// ✅ on the page that shows the query
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => <IdeaSkeleton />)
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(({ data: { idea } }) => <h1>{idea.title}</h1>)
// ❌ a query has no .loading / .error — type error
export const ideaQuery = root.lets.query() /* .loading(...) */A page, component, or provider has only the unified .loading and .error. A
provider renders in the page position, so its loading and error
components receive type: 'page'. The root, base, and
plugin have the full set, and a layout has six of them — see
the matrix at the bottom.
When data is pending, Point0 walks up the chain and uses the nearest .loading;
on a thrown error, the nearest .error. Declare once high, override low:
export const root = Point0.lets
.root()
.loading(() => <Spinner />) // the default for everything below
.error(({ error }) => <ErrorScreen error={error} />)
.root()
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => <IdeaSkeleton />) // overrides the root spinner for this page
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(/* ... */)Loading and error resolve independently: a page can override .loading and
still inherit the root's .error, or the other way round.
A plugin can carry its own .loading / .error; when you .use it
on a point, its declarations join the chain at that position and override what
came before — handy for shipping a loading/error theme as a plugin.
.loading in the chainAbove the loaders and queries. One habit covers every case:
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => <IdeaSkeleton />) // fallbacks first…
.error(({ error }) => <IdeaError error={error} />)
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) })) // …data below
.page(/* ... */)The reason is that each .loading / .error is positional: it opens a
Suspense/error boundary around the REST of the chain below it. Whatever suspends
or throws is caught by the closest boundary declared above it:
suspend: 'server' | 'client' | true, the
suspense hooks) suspends
into that boundary — one declared below the query cannot catch it..with that returns 'loading' or an error (see
below) renders the nearest
.loading / .error declared above it; one declared lower in the chain
wasn't in scope yet..error above it the same way.Only for a plain blocking query is placement relaxed: it doesn't suspend — every
chain query fetches in parallel and the point blocks on pending data right
before it renders (.page / .layout / .component), so the entry fallback
(the point's last-declared .loading) shows either way. That relaxation is
easy to outgrow — add suspend to that query later and a below-the-query
.loading silently stops applying. Declare fallbacks first and you never think
about it.
Chain position is what counts, not where the data was declared: a page's
.loading placed above such a .with overrides the loading even for a query
inherited from a base upstream.
On the root, base, plugin, or a layout — the points that span more than one render variant — you can target a single variant. A layout, for example, renders its own UI and hosts a page below it, so it can set loading for each independently:
export const ideasLayout = root.lets
.layout('/ideas')
.layoutLoading(() => <LayoutSkeleton />) // while the layout's own loader runs
.pageLoading(() => <PageSkeleton />) // while a page below it loads
.layoutError(({ error }) => <LayoutError error={error} />)
.pageError(({ error }) => <PageError error={error} />)
.layout(/* ... */)The full set, per render position:
.pageLoading / .pageError — for a page rendered below this point..layoutLoading / .layoutError — for this point's own layout render..componentLoading / .componentError — for a component rendered below.The plain .loading / .error are the collapsing aliases: on these
multi-variant points they set page, layout, and component at once. For the
active variant, a unified .loading wins over the variant-specific one — when
you declare both on the same point, the unified one is resolved first. A layout
has the page and layout variants only — it can't host arbitrary components in
this surface, so it has no .componentLoading / .componentError.
With SSR on, the server runs each point's loaders, renders the resolved HTML,
and ships the query cache alongside it as a dehydrated React Query state
(the same dehydrate/hydrate mechanism you'd use with TanStack Query by hand
— not loader data hand-inlined into the markup). The browser hydrates that
snapshot, so every server-loaded query is already success on first paint and
its loading component never appears:
// page + component both use a server .loader →
// the SSR HTML shows the real content, and the dehydrated cache
// hydrates so no query re-fetches or flashes "Loading..."The catch: this only holds for data the server actually has in that snapshot. A
.clientLoader runs in the browser, so the server has nothing to
dehydrate for it — its loading component does show in the SSR HTML and
resolves once the client takes over. See SSR.
On client navigation, whether the loading component flashes depends on the
prefetch policy. With no prefetch, the destination's data starts
loading after the click, so its .loading shows:
// prefetchPagePolicy 'none' / false → loading shows on every navigationPrefetch the page before the transition — on hover, or eagerly — and the data is already in cache when the route commits, so no loading appears:
export const ideaPage = root.lets
.page('/ideas/:id')
.prefetchPageOnLinkHover('serverAndClientQuery') // fetch on hover → no flash
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.page(/* ... */)serverAndClientQuery is the cheap policy (fetch the data, no SSR render);
pageDehydratedStateAndClientQuery does a full server render and is heavier.
The policies and .prefetchPageOnNavigate / .prefetchPageOnLinkHover are
covered on Navigation and SSR.
.loading covers a point that has no data yet. For the transition itself —
the gap between click and the next page committing — drive a top bar from the
navigation hook. Mount this once in your client app:
import { useOnNavigate } from '@point0/core/navigation'
import nprogress from 'nprogress'
export const NProgress = () => {
useOnNavigate(() => {
// runs when a navigation starts
const timeout = setTimeout(() => nprogress.start(), 30)
// the returned cleanup runs when the navigation ends
return () => {
clearTimeout(timeout)
nprogress.done()
}
})
return null
}The 30 ms timeout keeps the bar from flashing on instant transitions — a
convention, not a framework rule. Related: useIsNavigating() returns a boolean
you can use to dim the page during a transition (the basic example does this),
and useNavigationPageState() exposes the status / loading / error of the
current transition. All ship from @point0/core/navigation — see
Navigation.
.withWhen data isn't a plain query — you compute readiness from a hook, say — return
the reserved values from a .with function to render the same
components:
.with(() => {
const ready = useSomethingReady()
if (!ready) return 'loading' // → renders the active loading component
if (broke) return new ErrorPoint0('Failed', { status: 500 }) // → error component
}).with also hands you LoadingComponent and ErrorComponent as props, so you
can render them directly — return <LoadingComponent />. The full set of
reserved returns is on .with.
A loading or error screen is UI — it doesn't keep data off the client. The
browser bundle is public (after the initial SSR render, navigation is
client-side, SPA-style), so the page body ships to the browser; only server-only
code — .ctx, server .loader bodies — is stripped at compile time. Gate
access in a .with by returning an error, not by hiding content behind
a loading state:
import { authPlugin } from '@/lib/auth' // puts the user in props.me
import { ErrorPoint0 } from '@point0/core'
export const adminPage = root.lets
.page('/admin')
.use(authPlugin)
.with(({ props: { me } }) => {
if (!me?.isAdmin) return new ErrorPoint0('Forbidden', { code: 'FORBIDDEN' })
return { me }
})
.page(/* ... */)The returned error short-circuits to the error component. Because it carries a
status, it also sets the HTTP status during SSR. Don't use .ctx for this — a
.ctx gate runs only when the point has a loader, so a loader-less page never
fires it. See .with.
| Method | root / base / plugin | page / component / provider | layout |
|---|---|---|---|
.loading / .error | ✅ | ✅ | ✅ |
.pageLoading / .pageError | ✅ | — | ✅ |
.layoutLoading / .layoutError | ✅ | — | ✅ |
.componentLoading / .componentError | ✅ | — | — |
query, infinite-query, mutation, and action expose none of these. Neither does a finalized (ready) point — declare loading and error during the build phase, before the point is closed.
| Component | Props received | Notes |
|---|---|---|
| loading | { type } | type is 'page' | 'component' | 'layout' |
| error | { type, error } | error is an ErrorPoint0 (or your configured error class) |
.loading; thrown data →
nearest .error. Loading and error resolve independently..loading / .error opens a boundary around the
rest of the chain below it; a suspending query, a .with short-circuit, or a
render throw uses the nearest one declared above it. Only a plain blocking
query relaxes this (the point blocks at render, so the last-declared
.loading shows either way) — declare fallbacks above the loaders anyway..with, or a .mapper renders the
nearest .error in place and the rest of the page stays alive. The boundary
resets on navigation..loading /
.error wins over the variant-specific setter, which wins over the built-in
default..used plugin's .loading / .error joins the chain
at the .use position and overrides what came before..clientLoader's
loading still renders in the SSR HTML.serverAndClientQuery, pageDehydratedStateAndClientQuery,
the on-hover variants); 'none' / false shows it..redirect render a redirect instead of the error component.error.status) during SSR; off-server
it's a no-op..loading / .error and all
their per-variant forms ship to the client always and to the server only when
SSR is on (stripped under ssr: false or after .clientOnly())..error IS a React error boundary (and a data error path).error handles both error phases. As the data path, it fires when a loader
or query resolves to an error state, or when a .with returns an error
— Point0 surfaces that as the point's error status and renders the nearest
.error in its place. As a render boundary: every mountable render (page,
layout, component, provider) is wrapped in a React error boundary, so a throw
from a component body, a .with, or a .mapper is caught and
renders the nearest .error above it while the rest of the page stays alive.
During SSR the throw is contained the same way and carries the same effects into
the response a returned error does — its HTTP status applies, and a thrown
redirect becomes the real HTTP redirect; the HTML itself ships the mountable's
loading fallback and .error renders after hydration. The engine's SPA fallback
(serving the bare index.html) remains only for errors outside every boundary:
a broken root, a failing wrapper.
Details of the boundary behavior:
.with: it's typed, and it puts .error into the SSR HTML itself
(a throw ships the loading fallback there). The render boundary is the safety
net, not the API.