Skip to main content
query() connects your UI to your API with reactive data fetching, caching, and automatic cleanup.
NEVER use raw fetch() to call entity or service endpoints. The api object (from src/client.ts) is a fully typed SDK auto-generated from your entity and service definitions. It provides type safety, SSR integration, caching, and optimistic updates that raw fetch() cannot. For truly custom non-SDK endpoints, wrap fetch() in a query() thunk with a cache key (shown below).

Basic usage

query() accepts a query descriptor (from the typed API client) or a thunk that returns a promise:

Reactive properties

A query result exposes five reactive properties:
PropertyTypeDescription
dataT | undefinedThe fetched data, or undefined while loading
loadingbooleantrue only on initial load (not during revalidation)
revalidatingbooleantrue when refetching with existing data
errorError | undefinedThe error from the last failed fetch
idlebooleantrue when the query has never fetched (thunk returned null)
These are reactive — use them directly in JSX and event handlers:

Reactive dependencies

When the thunk reads a signal, the query automatically re-fetches when that signal changes:
The dependency tracking happens automatically — any signal read before the first await in the thunk becomes a dependency. Use include to fetch related data in a single query instead of making separate API calls and joining them client-side:
Nest includes to load deeper relations:
Relations must be exposed in the entity’s expose.include config on the server — see Fields, Relations & Filters.

Rendering query states

Use direct conditional rendering for loading, error, and data states:
The compiler auto-unwraps tasks.loading, tasks.error, and tasks.data as signal properties, making each branch reactive.

Early return when you need loaded data

Inline conditionals work for simple views, but they force you to nullcheck data everywhere — and you can’t call APIs like form(..., { initial: () => data.data }) that need data to be present at the call site. Use an early-return guard to split the component into “loading” and “ready” states:
The compiler recognizes this pattern (a series of if (…) return <jsx/> guards followed by a final return) and wires reactivity correctly across both the CSR mount and the AOT-compiled SSR output. Keep guards flat — a chain at the top of the function body. Don’t nest them inside other if blocks.

Refetching

Call refetch() to manually re-fetch:

Polling

Use refetchInterval to poll at a fixed interval:
Dynamic polling — adjust the interval based on data:
Polling automatically pauses when the browser tab is hidden and resumes when visible.

Conditional queries

Since query() is not a hook, you can call it conditionally. But when you need a query that starts idle and fetches later based on a reactive condition, return null from the thunk to skip fetching:
When the thunk returns null, the query stays idle — loading is false, data is undefined, and idle is true. As soon as the reactive dependency changes and the thunk returns a real value, the query fetches automatically:
The idle property distinguishes “hasn’t fetched yet” from “fetched but got no results” — useful for showing different UI states:

Dependent query chains

Use null-return queries to chain dependent fetches — a query that depends on another query’s result:
idle is a one-way flag — once the query fetches for the first time, idle becomes false and never returns to true, even if the thunk returns null again later. This prevents UI flicker when conditions change temporarily.

Options

Caching

Queries are cached in a shared in-memory cache. For entity-backed queries (from the typed API client), the cache key is derived from the HTTP method, path, and query parameters:
  • api.tasks.list()GET:/api/tasks
  • api.tasks.get('abc')GET:/api/tasks/abc
  • api.tasks.list({ status: 'done' })GET:/api/tasks?status=done
Each unique combination of endpoint + parameters gets its own cache entry. Query parameters are sorted alphabetically, so { status: 'done', page: 1 } and { page: 1, status: 'done' } produce the same key. For plain thunks (not using the API client), the cache key is derived from the function body and the reactive signal values it reads. Returning to a previously-seen set of dependency values produces the same cache key — enabling cache hits without re-fetching.

Stale-while-revalidate

When you navigate away and back, cached data is served instantly while a background revalidation runs. The user sees content immediately — no loading spinner for data that was already fetched.

Disposal preserves cache

Queries are auto-disposed when their component unmounts — no manual cleanup needed. Disposal stops reactive effects and timers but preserves the shared cache, so navigating back serves data instantly.

Auto-revalidation

When an entity mutation completes (create, update, delete), all active queries for that entity type are automatically revalidated in the background. No configuration needed — the mutation event bus handles it:
This works across components — if a sidebar shows task counts and the main area shows a task list, both update when a task is mutated.

Manual invalidation

For edge cases not covered by automatic revalidation (e.g., a custom server-side operation that affects entity data), use invalidate():
invalidate() matches by entity type and operation kind, not by cache key. This means invalidate(api.todos.list()) revalidates ALL active todo list queries — including query(api.todos.list({ status: 'done' })) and query(api.todos.list({ assignee: userId })). You don’t need to invalidate each filter variation individually. Existing data stays visible while the refetch happens in the background (SWR pattern).
invalidate() is an escape hatch. In most cases, automatic revalidation from entity mutations handles everything. Use it only for operations outside the standard entity CRUD flow.

Tenant-scoped invalidation

When using multi-tenancy, switchTenant() automatically invalidates all tenant-scoped queries (clearing cached data before refetching). If you need to manually trigger this:
Unlike invalidate(), this clears cached data immediately (no SWR stale window) — users never see data from the wrong tenant.

Optimistic updates

Entity mutations automatically apply optimistic updates — the UI reflects changes instantly while the server request is in flight:
No configuration needed. The generated SDK automatically:
  1. Applies the mutation optimistically to all queries that reference the entity
  2. Commits when the server confirms success
  3. Rolls back if the server returns an error
This works across all queries — if a task list and a task detail are both on screen, both update instantly when you mutate a task.

How it works

Entity data is stored in a normalized EntityStore. When a mutation fires:
  1. An optimistic layer is pushed onto the store with the pending changes
  2. All entity-backed queries read through the layer stack, seeing the optimistic state
  3. On server success, the layer is committed (merged into base data)
  4. On server error, the layer is rolled back (UI reverts to server truth)
This is automatic for all CRUD operations (create, update, delete) on generated SDK methods.

SSR data loading

query() automatically resolves data during SSR so the page arrives with content — no loading flash:
  1. Pass 1 (discovery) — The server renders your app, triggering query() calls. Each query registers its fetch promise.
  2. Await — The server waits for all queries to resolve (default 300ms timeout per query).
  3. Pass 2 (render) — The server renders again with cached data. Queries serve from cache, so loading is false.
  4. Hydration — The client picks up the streamed data from window.__VERTZ_SSR_DATA__ and skips the initial fetch.
SSR data loading requires your API calls to use FetchClient (or the generated SDK). During SSR, Vertz installs a fetch proxy that routes relative API URLs (e.g., /api/tasks) through the in-memory request handler — no HTTP round-trip needed. If you use raw fetch() instead, the query will fail during SSR and the page will render with a loading state, causing a visible flash when the client-side fetch completes.

Configuration

Auto field selection

The Vertz compiler automatically analyzes which fields your components access on query results and injects a select parameter — so the server only returns the columns you actually use. This works transparently for components within your codebase. For third-party npm components, you can optimize by narrowing the data you pass across the boundary.

Auto Field Selection

How auto field selection works, what triggers fallback to all fields, and how to optimize at third-party component boundaries.