Skip to content

Loading States

The naive approach to loading states is a top-level check: if the query is fetching, render a skeleton; otherwise render the real UI. The problem is that you end up building your layout twice, and any component that owns its own loading shape has to expose that structure to its parent.

Houdini solves this with the @loading directive. Rather than a separate skeleton branch, the data object itself carries pending placeholders. Components can check their own fields and render accordingly, without the route knowing anything about their internals.

Adding @loading to a query also changes how the page is delivered. The response splits at the query boundary: everything above it (layouts, navigation, any ancestor shell) is flushed in the initial HTML. The component that renders the query result streams in over the same HTTP connection as data resolves. Users see the page structure immediately rather than staring at a blank screen until all queries finish.

The @loading Directive

The @loading directive marks fields whose values should be replaced with a pending placeholder while the query is in flight. Rather than receiving null or undefined during a load, the data object always has a predictable shape you can render against.

Place @loading on any field in your query:

query ShowList {
shows @loading {
title
}
}

Houdini walks the query from the top down. Intermediate nodes keep their type (objects stay objects, lists stay lists). The deepest field tagged with @loading becomes the pending value:

query ShowList {
shows @loading {
title @loading
genre
}
}

In this case shows is always an array and title is the pending value, so we can safely iterate over shows and check title to know whether the data has arrived.

List Placeholders

When @loading appears on a list field, use the count argument to control how many placeholder items appear:

query ShowList {
shows @loading(count: 4) {
title @loading
}
}

This produces an array of four items while loading, each with a pending title. The default is 3 if count is omitted.

Cascade and Document-Level Loading

If you want every field in a subtree to be part of the loading shape, use cascade:

query ShowList {
shows @loading(cascade: true) {
title
genre
}
}

This is equivalent to putting @loading on every field under shows. For the entire query at once, put @loading on the operation itself:

query ShowList @loading {
shows {
title
genre
}
}

Composing Through Fragments

Fragment spreads can carry @loading too, which lets the component that owns the fragment define its own loading shape without the route knowing anything about it:

query ShowList {
shows @loading(count: 4) {
...ShowCard_show @loading
}
}

The fragment then defines its own loading shape independently:

fragment ShowCard_show on Show {
title @loading
posterUrl @loading
}

The route stays decoupled from the loading structure of its children.

Streaming Boundaries

When a query includes @loading, Houdini wraps the route component in a React Suspense boundary and streams the result to the browser using React’s built-in streaming support. The split happens at the query:

initial HTML flush
├── root layout ← rendered immediately
│ ├── nav ← rendered immediately
│ └── <Suspense>
│ └── ShowsPage ← streams in when data resolves
│ └── ShowCard (pending titles, posters...)

The layout’s HTML (navigation, sidebars, any shell chrome) arrives in the first network chunk. The Suspense boundary holds the page content until the query resolves, at which point React streams the rendered HTML and hydrates it in place. The browser shows a real, interactive shell while the data-heavy content loads.

There is nothing to configure. The boundary is created automatically when @loading appears on a query, and torn down once the data is ready.

Checking for Pending Values in React

Use isPending from $houdini to check whether a value is a placeholder. Do not compare directly against PendingValue (that pattern is incompatible with React 18’s concurrent rendering):

import { graphql, useFragment, isPending } from '$houdini'
import type { ShowCard_show } from '$houdini'
export function ShowCard({ show }: { show: ShowCard_show }) {
const data = useFragment(show, graphql(`
fragment ShowCard_show on Show {
title @loading
posterUrl @loading
}
`))
return (
<div>
<img src={isPending(data.posterUrl) ? '/placeholder.png' : data.posterUrl} alt="" />
<h2>{isPending(data.title) ? <span className="skeleton" /> : data.title}</h2>
</div>
)
}
import { graphql, useFragment, isPending } from '$houdini'
export function ShowCard({ show }) {
const data = useFragment(show, graphql(`
fragment ShowCard_show on Show {
title @loading
posterUrl @loading
}
`))
return (
<div>
<img src={isPending(data.posterUrl) ? '/placeholder.png' : data.posterUrl} alt=""/>
<h2>{isPending(data.title) ? <span className="skeleton"/> : data.title}</h2>
</div>
)
}

isPending is a type guard, so TypeScript narrows the type correctly on both branches.