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, a query error is thrown into the Domphy element tree. The nearest ancestor with _onError catches it:
const queryClient = new QueryClient()
const user = createQuery(queryClient, {
queryKey: () => ["user"],
queryFn: fetchUser,
throwOnError: true, // throw errors into the element tree
})
const ErrorBoundary = {
div: UserSection,
_onError: (error, reset) => ({
div: [
{ p: `Error: ${error.message}` },
{ button: "Retry", onClick: reset },
],
}),
}This mirrors React's Suspense + ErrorBoundary pattern but without React.
Controlled pending with suspense flag
Use suspense: true to make the query participate in a Domphy-managed pending state. The component pauses rendering until the query resolves:
const queryClient = new QueryClient()
const post = createQuery(queryClient, {
queryKey: () => ["post", postId],
queryFn: () => fetchPost(postId),
suspense: true,
})
// With suspense: true, post.data(l) is always defined — no undefined check needed
const PostContent = {
article: [
{ h1: (l) => post.data(l)!.title },
{ p: (l) => post.data(l)!.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.