Skip to content
Domphy

Suspense & Streaming

Suspense-like patterns in Domphy

Domphy doesn't use React Suspense, but the same pattern — "show a fallback while loading, render when ready" — works with reactive state.

The simplest approach uses _onError as an error boundary and conditional rendering for the pending state:

import { QueryClient } from "@domphy/query"
import { createQuery } from "@domphy/query/domphy"

const queryClient = new QueryClient()

const user = createQuery(queryClient, {
  queryKey: ["user"],
  queryFn: fetchUser,
})

const UserPage = {
  div: (l) => {
    if (user.isPending(l)) return { div: "Loading…" }
    if (user.isError(l))   return { div: "Error loading user" }
    return UserContent
  },
}

throwOnError — propagate errors up

When throwOnError: true, reading any query field with a listener (the reactive render path) throws result.error into the Domphy element tree. The nearest ancestor with _onError / errorBoundary() catches it. Imperative reads without a listener never throw (so you can still inspect query.error() outside render).

import { errorBoundary } from "@domphy/ui"
import { QueryClient } from "@domphy/query"
import { createQuery } from "@domphy/query/domphy"

const queryClient = new QueryClient()

const user = createQuery(queryClient, {
  queryKey: ["user"],
  queryFn: fetchUser,
  throwOnError: true,   // throw on reactive field reads when status is error
})

const UserSection = {
  div: (l) => {
    // With throwOnError, an error here bubbles to errorBoundary / _onError
    const data = user.data(l)
    return { p: data ? data.name : "…" }
  },
}

const Page = {
  div: [UserSection],
  $: [
    errorBoundary({
      fallback: (error, reset) => ({
        div: [
          { p: `Error: ${(error as Error).message}` },
          { button: "Retry", onClick: () => { user.refetch(); reset() } },
        ],
      }),
    }),
  ],
}

This mirrors TanStack React Query's render-time throw + Error Boundary pattern, without React Suspense.

Controlled pending (no React Suspense)

Domphy has no React-style Suspense that pauses a parent while a promise settles. Use explicit isPending / isError branches (see above). The core option suspense: true is accepted for TanStack Query interop (it also defaults throwOnError to true in the core client) but does not guarantee data(l) is defined — always gate on status:

const post = createQuery(queryClient, {
  queryKey: ["post", postId],
  queryFn: () => fetchPost(postId),
  // optional: throwOnError: true  // same as React Query suspense default
})

const PostContent = {
  article: (l) => {
    if (post.isPending(l)) return { p: "Loading…" }
    if (post.isError(l)) return { p: "Failed" }
    const data = post.data(l)!
    return [
      { h1: data.title },
      { p: data.body },
    ]
  },
}

The parent shows a fallback while the query is pending:

import { spinner } from "@domphy/ui"

const PostPage = {
  div: (l) => post.isPending(l)
    ? { div: null, $: [spinner()] }
    : PostContent,
}

Deferred / background data

Separate critical data from non-critical data — render the page with placeholder content for slow queries:

const queryClient = new QueryClient()

const criticalData = createQuery(queryClient, {
  queryKey: ["page", id],
  queryFn: () => fetchPage(id),
})

const slowStats = createQuery(queryClient, {
  queryKey: ["stats", id],
  queryFn: () => fetchStats(id),
  // Stats can be deferred — render a placeholder and update when ready
})

const Page = {
  div: [
    // Critical content — shown immediately (with skeleton while loading)
    {
      article: (l) => criticalData.isPending(l) ? SkeletonArticle : Article(criticalData.data(l)!),
    },
    // Stats — deferred, shows loading indicator independently
    {
      aside: (l) => slowStats.isPending(l) ? { div: "Loading stats…" } : Stats(slowStats.data(l)!),
    },
  ],
}

SSR streaming

With @domphy/app's SSR mode, queries can stream their data progressively. The server renders the page shell immediately, then flushes query results as they resolve:

import { QueryClient, dehydrate, hydrate } from "@domphy/query"

// Server-side route loader
export async function loader({ params }) {
  const queryClient = new QueryClient()

  // Critical data — await before sending first byte
  await queryClient.prefetchQuery({
    queryKey: ["post", params.id],
    queryFn: () => fetchPost(params.id),
  })

  // Non-critical data — prefetch but don't block
  queryClient.prefetchQuery({
    queryKey: ["related", params.id],
    queryFn: () => fetchRelated(params.id),
  })

  return {
    dehydratedState: dehydrate(queryClient),
  }
}

// Client-side — hydrate the server-side cache into the client queryClient
hydrate(queryClient, loaderData.dehydratedState)

const PostPage = {
  div: PostContent,
}

Waterfall prevention

Avoid query waterfalls (query 1 loads → query 2 starts → query 3 starts) by prefetching all queries for a page in the loader:

// Instead of:
// Component A mounts → starts query A
// Component B (in A) mounts → starts query B (after A resolves)

// Do this:
async function prefetchAll(client: QueryClient, params: PageParams) {
  await Promise.all([
    client.prefetchQuery({ queryKey: ["user"], queryFn: fetchUser }),
    client.prefetchQuery({ queryKey: ["posts", params.userId], queryFn: () => fetchPosts(params.userId) }),
    client.prefetchQuery({ queryKey: ["settings"], queryFn: fetchSettings }),
  ])
}

All queries start simultaneously — no waterfall.

Global loading indicator

Show a top-level loading bar when any query is in-flight. Subscribe to the QueryCache to track active fetches:

import { QueryClient } from "@domphy/query"
import { toState } from "@domphy/core"

const queryClient = new QueryClient()
const fetchingCount = toState(0)

// Track global fetch count via QueryCache events
queryClient.getQueryCache().subscribe(() => {
  const count = queryClient.isFetching()
  fetchingCount.set(count)
})

const LoadingBar = {
  div: null,
  hidden: (l) => fetchingCount.get(l) === 0,
  style: {
    position: "fixed",
    top: 0, left: 0, right: 0,
    height: "2px",
    background: "var(--primary-5)",
    animation: "indeterminate 1s linear infinite",
  },
}

queryClient.isFetching() returns the count of in-flight queries — 0 when nothing is loading.