Point0by 1gr14
  • Меню
    • GitHub
    • Блог
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Point0by 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

Loading & error

  • Раздел: Methods

On this page

  1. The component contracts
  2. Where to declare them
  3. Nearest up the chain wins
  4. Where to put .loading in the chain
  5. Per-variant declarations
  6. SSR hides the first-paint loading — for server data
  7. Prefetch decides loading on navigation
  8. A top progress bar (NProgress)
  9. Driving loading and error by hand from .with
  10. Security: don't gate auth with loading/error alone
  11. Reference
  12. Which points have which
  13. The component props
  14. Resolution rules
  15. .error IS a React error boundary (and a data error path)

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 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 Error

type 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 .loading and .error and 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

Above 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:

  • A streamed or suspending query (suspend: 'server' | 'client' | true, the suspense hooks) suspends into that boundary — one declared below the query cannot catch it.
  • 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.
  • A render throw lands in the nearest .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.

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.

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 navigation

Prefetch 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. 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

Methodroot / base / pluginpage / component / providerlayout
.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.

The component props

ComponentProps receivedNotes
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. Each .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.
  • Render throws are caught. Every mountable render is wrapped in an error boundary; a throw in a component body, a .with, or a .mapper renders the nearest .error in place and the rest of the page stays alive. The boundary resets on navigation.
  • Unified beats variant. For the active render variant, a plain .loading / .error wins over the variant-specific setter, which wins over the built-in default.
  • Plugins override. A .used plugin's .loading / .error joins the chain at the .use position 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' / false shows it.
  • Errors with .redirect render 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 / .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:

  • A thrown redirect passes through the boundary and navigates as usual — during SSR it produces the real HTTP redirect.
  • The boundary resets on navigation — moving to another location drops the caught error and re-renders. It does not reset on a query refetch: query errors normally travel through query state (never thrown), so if your error UI refetches without navigating, render a fresh state from the data phase instead of relying on the boundary to clear.
  • Prefer signaling errors from the data phase anyway — return an error from a .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.
НазадMiddlewareДалее.with

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter