Query Every day
Reading
createQuery binds an endpoint and its params to a component: the request runs when the result is read on screen, and every field is its own signal.
Markdown version of this page, for assistants (Markdown for AI)
On this page
States
Press Refetch: the user waits, so loading. Press Invalidate: a quiet refresh, so refreshing, and the list stays.
The example runs in the browser.
Requests the server received
- None yet.
Code
<script lang="ts">
import { createQuery, invalidate } from '@svelte-lean/query';
import { listMembers } from '$api/members';
const members = createQuery(listMembers, () => ({}));
</script>
{#if members.info.status === 'pending'}
<p>Loading members…</p>
{:else if members.error && !members.data}
<p role="alert">The members could not be loaded ({members.error.kind}).</p>
{:else}
{#if members.refreshing}<progress aria-label="Refreshing"></progress>{/if}
<ul aria-busy={members.loading}>
{#each members.data?.rows ?? [] as member (member.id)}
<li>{member.name}</li>
{/each}
</ul>
{/if}
<button onclick={() => members.refetch()}>Refetch</button>
<button onclick={() => invalidate('Members')}>Invalidate Members</button>
<p>
loading {members.loading} · refreshing {members.refreshing} · cause {members.info.fetch?.cause ??
'none'}
</p>| Field | Meaning |
|---|---|
data | The server value with optimistic layers applied; undefined before the first answer. |
error | A QueryError; the data it had stays (except kind forbidden). |
loading | A fetch the user waits for: the first one, a new key without data, refetch(). |
refreshing | A quiet fetch while data is shown: poll, focus, invalidation, live event. |
busy | loading or refreshing. |
blocked | Why nothing is fetched: 'access', 'waiting' or 'when'. |
refetch(), load() | Fetch the current key now; start the binding by hand. |
The rest is under info, each field still its own signal:
| Field | Meaning |
|---|---|
info.status | 'idle' (blocked), 'pending' (no data yet), 'success' or 'error'. |
info.placeholder | data belongs to the previous key or to the placeholder option. |
info.stale | The value is older than fresh. |
info.slow | The current fetch has run longer than slowAfter. |
info.updatedAt | Time of the last answer; 0 before the first. |
info.fetch | The running fetch, else the last one: cause, reason, source, attempt. |
An effect that reads data does not run when refreshing flips, and an answer
equal to the cached one writes nothing.
Loading and refreshing
| Situation | loading | refreshing |
|---|---|---|
| First fetch of a key, nothing shown | yes | no |
| New key without data, previous rows shown (keepPrevious) | yes | no |
| New key with stale data of its own | no | yes |
| refetch(), ep.fetch(…, { from: "network" }) | yes | no |
| Poll, focus, reconnect, live event, invalidation, retry | no | yes |
Dim the rows on loading; show a thin bar on refreshing.
Keep previous
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listMembers } from '$api/members';
let search = $state('');
// The rows of the previous search stay on screen while the next ones load.
const members = createQuery(listMembers, () => ({ search }), { keepPrevious: true });
</script>
<input type="search" aria-label="Search members" bind:value={search} />
<!-- `info.placeholder` is true while the rows belong to the previous search. -->
<ul class:dimmed={members.info.placeholder}>
{#each members.data?.rows ?? [] as member (member.id)}
<li>{member.name}</li>
{/each}
</ul>
<style>
.dimmed {
opacity: 0.6;
}
</style>While the next answer is out, data holds the previous rows and info.placeholder is true. Typing fast aborts the requests nobody waits for any more.
A placeholder
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { getMember } from '$api/members';
import type { Member } from '$api/types';
// The row the user clicked in the list: shown at once while the full record loads.
let { id, row }: { id: number; row: Member | undefined } = $props();
const member = createQuery(getMember, () => ({ id }), { placeholder: () => row });
</script>
<!-- `info.placeholder` is true while `data` is the row, not the server's answer. -->
<article class:dimmed={member.info.placeholder} aria-busy={member.loading}>
<h2>{member.data?.name ?? 'Member'}</h2>
<p>{member.data?.email}</p>
</article>
<style>
.dimmed {
opacity: 0.6;
}
</style>The placeholder option is data to show while the key has none: a value, or a
function read when the binding needs it. It is never cached and never sent anywhere; info.placeholder is true while it is shown, and the server's answer replaces it. keepPrevious takes precedence when both are set and previous data exists.
When a query runs
A query starts when its result is read in the template (or a $derived or $effect). Everything else narrows that. A read outside a reactive context (an event
handler, untrack(), a formatter called outside the template) returns the current
value and starts nothing; development says so once (slq/untracked-read). For a
table column whose formatter needs a query, read the query in the section that builds the
columns. when and poll may read the binding's own data (stop refreshing a finished job); write the binding's type then (const job: QueryResult<Job> = createQuery(…)).
| Need | How | Example |
|---|---|---|
| It should load when shown | Read it inside {#if} | dialog body, expanded row, tab panel |
| Pause while the component stays mounted | when: () => open | a collapsed panel |
| A required input is missing | return undefined from the params | a detail without an id |
| A button starts it | { when: false } and load(params) | a search form |
| One read outside the UI | ep.fetch(params) | a check before an export |
| Prepare before it is opened | ep.prefetch(params) | pointer enters a row |
A state condition
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listTeams } from '$api/members';
let open = $state(false);
// Mounted with the page, fetched only while the panel is open; closing it keeps the data.
const teams = createQuery(listTeams, { when: () => open });
</script>
<details bind:open>
<summary>Teams</summary>
{#if teams.loading}<p>Loading…</p>{/if}
<ul>
{#each teams.data ?? [] as team (team.id)}
<li>{team.name} ({team.members})</li>
{/each}
</ul>
</details>A missing input
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { getMember } from '$api/members';
let { selected }: { selected: number | null } = $props();
// No id, no request (`blocked` reads 'when') until one is selected.
const member = createQuery(getMember, () => (selected === null ? undefined : { id: selected }));
</script>
{#if selected === null}
<p>Select a member.</p>
{:else if member.data}
<h2>{member.data.name}</h2>
<p>{member.data.email}</p>
{/if}A button
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listMembers } from '$api/members';
// Nothing runs until load(params); then the binding shows those params (cached, refetch).
const results = createQuery(listMembers, { when: false, keepPrevious: true });
</script>
<form
onsubmit={(event) => {
event.preventDefault();
const search = String(new FormData(event.currentTarget).get('search') ?? '');
void results.load({ search });
}}
>
<input type="search" name="search" aria-label="Search members" />
<button disabled={results.loading}>Search</button>
</form>load() always sends a request (cause user) and pins its params;
automatic refetches stay paused while when is false. More in Search button.
Why nothing is fetched
| blocked | When | Cached value |
|---|---|---|
access | the access rule says false | removed |
waiting | the access rule is not known yet | kept and shown |
when | when is false, or the params returned undefined | kept and shown (no key without params) |
Polls, focus, invalidations and live events send nothing for a blocked binding.
Polling
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listMembers } from '$api/members';
let every = $state<'10s' | '30s' | 'off'>('30s');
// The fastest active use wins; polls pause while the tab is hidden and show `refreshing`.
const members = createQuery(listMembers, () => ({}), {
poll: () => (every === 'off' ? false : every)
});
</script>
<label>
Refresh
<select bind:value={every}>
<option value="10s">every 10 seconds</option>
<option value="30s">every 30 seconds</option>
<option value="off">off</option>
</select>
</label>
{#if members.refreshing}<progress aria-label="Refreshing"></progress>{/if}Every polled entry shares one timer per client. A poll waits while the tab is hidden, never
shows loading, and joins a request already out. A job that polls until it ends is
declared on the endpoint: Watch a job until it ends.
Errors
| kind | Retried | Meaning |
|---|---|---|
network | yes | The request did not reach the server. |
timeout | yes | The attempt ran longer than the transport timeout. |
http | 5xx, 408 and 429 | Any other status; 4xx are final. |
refused | no | A 2xx answer that says success: false (with a profile). |
validation | no | Field errors (422); see Forms. |
forbidden | no | Access is false, or the server answered 401 or 403: cached data is removed. |
parse | no | The answer failed the result schema. |
The package shows nothing on its own: no toast, no console output. kind, status and code are what a message needs.
Several queries as one state
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listMembers, listTeams } from '$api/members';
const members = createQuery(listMembers, () => ({}));
const teams = createQuery(listTeams, { poll: () => '30s' });
// One state for the page is a $derived over its bindings; the polled teams only refresh.
const loading = $derived(members.loading || teams.loading);
const refreshing = $derived(members.refreshing || teams.refreshing);
const error = $derived(members.error ?? teams.error);
const refetch = () => Promise.all([members.refetch(), teams.refetch()]);
</script>
{#if loading}<p>Loading…</p>{:else if refreshing}<progress aria-label="Refreshing"></progress>{/if}
{#if error}<p role="alert">Part of the page could not be loaded.</p>{/if}
<button onclick={refetch}>Refresh</button>A page state is a $derived over its bindings; nothing to import.
The loading hint
const table = createQuery(listMembers, () => ({}), { poll: () => '10s' }); // a poll: refreshing
table.refetch({ loading: false }); // a user refresh shown as a thin bar, rows stay
invalidate('Members', { reason: 'bulk-import', loading: true }); // dim every list it touches
// A badge that should never flash reads data only; "show each poll" reads busy.{ loading: true | false } on refetch, load, invalidate, ep.fetch, ep.invalidate and run shows that fetch as loading or refreshing instead of its cause's
default. info.status: 'pending' still says "no data".