Loading & error
- Category: Methods
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. The rest of this page shows where each piece comes from.
The component contracts
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. The error class is ErrorPoint0 unless you swap it for your own — 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.
These are server-ssr-and-client methods (see
stage-methods): .loading / .error and their per-variant
forms (.pageLoading, .layoutError, …) are render-side, so they always ship
to the browser bundle and run wherever the point renders. They also run on the
server, but only when SSR is on — 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.
Where to declare them
.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.
Nearest up the chain wins
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.
Where to put .loading in the chain
Inside one point, the placement of .loading relative to a .with(query) is
relaxed. Wiring a query in with .with(query) doesn't suspend on its own —
every query on the chain starts fetching in parallel, and the point only
blocks on that pending data right before it renders (.page / .layout /
.component). So .loading works whether it sits before or after the .with:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
.loading(() => <IdeaSkeleton />) // applies — render is what blocks, not .with
.page(/* ... */)Order does matter when a .with callback itself decides to show loading or
throw — there the rule is sequential. A .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:
export const ideaPage = root.lets
.page('/ideas/:id')
.loading(() => <IdeaSkeleton />) // ✅ in scope for the .with below
.with(() => (useSomethingReady() ? undefined : 'loading'))
.page(/* ... */)Chain position is what counts, not where the data was declared: a page's
.loading placed before such a .with overrides the loading even for a query
inherited from a base upstream.
Per-variant declarations
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.
Every variant above is server-ssr-and-client, same as the collapsing
.loading / .error: always on the client, on the server only under SSR.
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.
SSR hides the first-paint loading — for server data
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.
Prefetch decides loading on navigation
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.
A top progress bar (NProgress)
.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. useOnNavigate(fn) runs fn at the start of
a navigation and its returned cleanup at the end. 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.
Driving loading and error by hand from .with
When 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.
Security: don't gate auth with loading/error alone
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.
Reference
Which points have which
| 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. So does a finalized (ready) point — declare loading and error during the build phase, before the point is closed.
The component props
| Component | Props received | Notes |
|---|---|---|
| loading | { type } | type is 'page' | 'component' | 'layout' |
| error | { type, error } | error is an ErrorPoint0 (or your configured error class) |
Resolution rules
- Nearest up the chain. Pending data → nearest
.loading; thrown data → nearest.error. Loading and error resolve independently. - Position matters.
.loading/.errorapply only to data methods declared after them — declare them before the suspending.loader/.with. - Unified beats variant. For the active render variant, a plain
.loading/.errorwins over the variant-specific setter, which wins over the built-in default. - Plugins override. A
.used plugin's.loading/.errorjoins the chain at the.useposition and overrides what came before. - SSR hides loading for server-available data only; a
.clientLoader's loading still renders in the SSR HTML. - Prefetch hides loading on navigation when the data is fetched before the
route commits (
serverAndClientQuery,pageDehydratedStateAndClientQuery, the on-hover variants);'none'/falseshows it. - Errors with
.redirectrender a redirect instead of the error component. - The error sets the HTTP status (
error.status) during SSR; off-server it's a no-op. - Strip category — server-ssr-and-client.
.loading/.errorand all their per-variant forms ship to the client always and to the server only when SSR is on (stripped underssr: falseor after.clientOnly()).
.error vs a React error boundary
.error is a data error path, not a render error boundary. 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. It does not catch a throw from your own render: a
component body that throws, or a .mapper that throws, is a
render-phase error, and Point0 ships no React error boundary around your tree.
An uncaught render throw bubbles to whatever boundary your app mounts (and fails
the SSR render). So keep render code total and signal errors from the data phase
— return an error from a .with, and .error handles it.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️