Skip to content

Loading States

WebJs uses loading.ts files to automatically wrap page content in a Suspense boundary. The loading UI is flushed to the browser immediately while the async page function resolves in the background.

When to use

  • Pages with slow data fetching (database queries, external API calls).
  • Any page where you want instant visual feedback instead of a blank screen.

When NOT to use

  • For fast pages that render in under 100ms. The loading state would flash and disappear, which is worse UX than waiting.
  • For client-side loading within a component. Use the Task controller instead.

Basic usage

Create a loading.ts file next to your page. The framework automatically wraps the page in a Suspense boundary using your loading component as the fallback:

// app/blog/loading.ts
import { html } from '@webjsdev/core';

export default function Loading() {
  return html`
    <div class="skeleton">
      <div class="skeleton-title"></div>
      <div class="skeleton-line"></div>
      <div class="skeleton-line"></div>
    </div>
  `;
}
// app/blog/page.ts: this is async and may be slow
import { html } from '@webjsdev/core';

export default async function BlogPage() {
  const posts = await fetchPosts();  // slow DB query
  return html`<ul>...`;
}

What happens

  1. The server renders the loading fallback and flushes it to the browser immediately.
  2. The page function runs in the background.
  3. When the page resolves, the server streams a <template> chunk that replaces the fallback with the real content, with no client JavaScript needed for the swap.

Nesting

loading.ts files apply at their directory level. You can have different loading states for different sections:

app/
  loading.ts            ← fallback for the root page
  blog/
    loading.ts          ← fallback for all /blog/* pages
    [slug]/page.ts      ← wrapped by blog/loading.ts

Loading states on client navigation

The same loading.ts also feeds client-side navigation. The SSR pipeline emits each segment's loading content as a hidden <template id="wj-loading:<segment-path>"> at body end. When a user clicks a link, the router clones the deepest matching template into the swap slot immediately, before the fetch even completes, so the user sees the skeleton instantly instead of stale content from the previous page.

If the fetch fails (network error, server crash), the optimistic loading content is reverted and the router falls back to a full page navigation. The same files serve both server-side SSR Suspense and client-side nav optimistic UI. Write one loading.ts, get both behaviors.

Manual Suspense

For more control, use Suspense() directly in your page template instead of a loading.ts file:

import { html, Suspense } from '@webjsdev/core';

export default function Page() {
  return html`
    <h1>Dashboard</h1>
    ${Suspense({ fallback: html`<p>Loading stats…</p>`, children: loadStats() })}
    ${Suspense({ fallback: html`<p>Loading feed…</p>`, children: loadFeed() })}
  `;
}

Each Suspense boundary resolves independently, so the feed can appear before the stats if it's faster.

Component re-fetch loading: renderFallback()

The loading states above are for a PAGE or REGION (a loading.ts skeleton, a Suspense boundary, a streamed <webjs-suspense>). A component that fetches its own data with async render has a separate concern: what to show when it RE-FETCHES on the client (a prop / dependency change re-runs async render()).

The default is stale-while-revalidate: the component keeps its current content until the new render resolves (no blank, no flash). Define renderFallback() ONLY to override that with an explicit loading state DURING the re-fetch. It is shown only on a client re-fetch, never on the first paint, and it does NOT trigger SSR streaming (to stream slow data on the first paint, wrap the component in <webjs-suspense> instead).

class UserActivity extends WebComponent({ uid: String }) {
  renderFallback() { return html`<div class="skeleton h-24"></div>`; }   // re-fetch only
  async render() {
    const items = await getActivity(this.uid);
    return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
  }
}

Next steps