sveltelean Query
Version v0.2.0

States

Press Refetch: the user waits, so loading. Press Invalidate: a quiet refresh, so refreshing, and the list stays.

Loading and refreshing

The example runs in the browser.

Requests the server received

  1. 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>
The component runs against the in-memory server of the overview.
FieldMeaning
dataThe server value with optimistic layers applied; undefined before the first answer.
errorA QueryError; the data it had stays (except kind forbidden).
loadingA fetch the user waits for: the first one, a new key without data, refetch().
refreshingA quiet fetch while data is shown: poll, focus, invalidation, live event.
busyloading or refreshing.
blockedWhy 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:

FieldMeaning
info.status'idle' (blocked), 'pending' (no data yet), 'success' or 'error'.
info.placeholderdata belongs to the previous key or to the placeholder option.
info.staleThe value is older than fresh.
info.slowThe current fetch has run longer than slowAfter.
info.updatedAtTime of the last answer; 0 before the first.
info.fetchThe 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

Situationloadingrefreshing
First fetch of a key, nothing shownyesno
New key without data, previous rows shown (keepPrevious)yesno
New key with stale data of its ownnoyes
refetch(), ep.fetch(…, { from: "network" })yesno
Poll, focus, reconnect, live event, invalidation, retrynoyes

Dim the rows on loading; show a thin bar on refreshing.

Keep previous

search.svelte
<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

member-panel.svelte
<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(…)).

NeedHowExample
It should load when shownRead it inside {#if}dialog body, expanded row, tab panel
Pause while the component stays mountedwhen: () => opena collapsed panel
A required input is missingreturn undefined from the paramsa detail without an id
A button starts it{ when: false } and load(params)a search form
One read outside the UIep.fetch(params)a check before an export
Prepare before it is openedep.prefetch(params)pointer enters a row

A state condition

teams-panel.svelte
<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

member-detail.svelte
<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

member-search.svelte
<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

blockedWhenCached value
accessthe access rule says falseremoved
waitingthe access rule is not known yetkept and shown
whenwhen is false, or the params returned undefinedkept and shown (no key without params)

Polls, focus, invalidations and live events send nothing for a blocked binding.

Polling

members-toolbar.svelte
<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

kindRetriedMeaning
networkyesThe request did not reach the server.
timeoutyesThe attempt ran longer than the transport timeout.
http5xx, 408 and 429Any other status; 4xx are final.
refusednoA 2xx answer that says success: false (with a profile).
validationnoField errors (422); see Forms.
forbiddennoAccess is false, or the server answered 401 or 403: cached data is removed.
parsenoThe 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

members-page.svelte
<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

loading and reason
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".