sveltelean Query
Version v0.2.0

At a glance

Search, then pin a member. The panel lists what the server received and why: mount, params, mutation, invalidate.

Members

The example runs in the browser.

Requests the server received

  1. None yet.
Code
<!-- src/routes/members/+page.svelte -->
<script lang="ts">
	import { createMutation, createQuery } from '@svelte-lean/query';
	import { listMembers, pinMember } from '$api/members';

	let filters = $state({ search: '' });
	const members = createQuery(listMembers, () => filters, {
		keepPrevious: true,
		poll: () => '30s'
	});
	const pin = createMutation(pinMember);
</script>

<input type="search" aria-label="Search members" bind:value={filters.search} />
{#if members.loading}<p>Loading members…</p>{/if}
{#if members.refreshing}<progress aria-label="Refreshing"></progress>{/if}

<ul>
	{#each members.data?.rows ?? [] as member (member.id)}
		<li>
			{member.name}
			<button
				disabled={pin.busy || !pin.allowed()}
				onclick={() => pin.run({ id: member.id, pinned: !member.pinned })}
			>
				{member.pinned ? 'Unpin' : 'Pin'}
			</button>
		</li>
	{/each}
</ul>
The component above is the file shown; an in-memory server answers in place of http(), with a fixed delay.
src/lib/api/members.ts
import { defineMutation, defineQuery, page, tag, type } from '@svelte-lean/query';
import type { Member, MemberFilters, MemberInput, Team } from './types';

// The tag and permission registries: a typo in a tag or a permission is a compile error.
declare module '@svelte-lean/query' {
	interface Tags {
		Member: number;
		Members: void;
		Teams: void;
	}
	interface Permissions {
		'members.view': true;
		'members.edit': true;
		'members.delete': true;
	}
}

export const listMembers = defineQuery({
	get: 'api/members',
	params: type<MemberFilters>(),
	result: page<Member>(),
	entity: 'Member', // every row carries tag('Member', row.id)
	tags: ['Members'],
	fresh: '15s',
	access: 'members.view'
});

export const pinMember = defineMutation({
	put: 'api/members/{id}/pin',
	body: type<{ pinned: boolean }>(),
	result: type<Member>(),
	invalidates: (member) => [tag('Member', member.id)],
	queue: (input) => `member:${input.id}` // one write per member at a time, in order
});
src/routes/+layout.svelte
<!-- src/routes/+layout.svelte — one client for the application -->
<script lang="ts">
	import { createClient, setClient } from '@svelte-lean/query';
	import { http } from '@svelte-lean/query/http';
	import { can, session } from '$api/session.svelte';

	let { children } = $props();

	setClient(
		createClient({
			transport: http({
				base: '/',
				headers: () => ({ authorization: `Bearer ${session.token}` })
			}),
			scope: () => [session.workspace], // part of every cache key
			can // resolves the permission strings of `access` rules
		})
	);
</script>

{@render children()}

What happens without more code

  • Typing changes the params; the previous rows stay on screen while the next ones load.
  • A request whose answer nobody waits for any more is aborted.
  • The list polls only while the tab is visible; a poll sets refreshing, never loading.
  • Pinning refetches only the observed entries tagged Member:<id>, once, even when a poll lands at the same time.
  • Without the members.view permission, nothing is sent.

Install

One package; every entry point below is a subpath of it. Its only peer dependency is svelte ^5.20.0. Then create the client: Client setup.

npm install @svelte-lean/query

Levels

A screen should need level 1 only. The guides and the reference follow the same order.

LevelNamesUsed forGuides
1 · every daydefineQuerydefineMutationcreateQuerycreateMutationrundataloadingrefreshingerrorbusyMost screens
2 · oftenkeepPreviouspollwhenload()invalidate()patch()optimisticcreatePagedcreateInfiniteallowed()Tables, dialogs, forms
3 · advancedinfo{ loading }reasonentitydefineModulesend()partslivepersistComplex pages, realtime

When a query runs

A query starts when its result is read on screen. Everything else narrows that.

NeedHowRecipe
It should load when shownnothing: read it inside {#if}Load when a dialog opens
A state condition: dialog, tabwhen: () => openReading
A required input is missingparams return undefinedDependent queries
A button starts it{ when: false } + load(params)Search button

Entry points

Each module is its own import; the root entry never re-exports one. A screen that reads and writes ships 15409 B gzip; a screen that only reads ships 11787 B gzip.

Entry pointStabilityContainsMeasuredgzipbrotli
@svelte-lean/querystable targetDefinitions, the client, createQuery, createMutation, invalidate, patch, errorsqueryquery+mutationroot (every export) 11787 B15409 B16134 B 10839 B14167 B14844 B
./httpstable targetThe fetch transport: base, headers, timeout, typed errors, uploads, downloadshttp (marginal) 2239 B 2079 B
./laravelstable targetA platform profile: envelope, refusals, field errors, paginators, query stringslaravel (marginal) 1681 B 1548 B
./pagedstable targetServer-paged lists and the props of <DataTable manual>paged (marginal) 1234 B 1109 B
./formsstable targetServer field errors into native form stateforms (marginal) 1406 B 1226 B
./partsstable targetOptional engine parts included up front with createClient({ parts })every part (static, marginal)query + every part (static) 3880 B15667 B 3544 B14383 B
./testingstable targetA test client with virtual time and typed mocks; MSW handlerstesting (not shipped) 18878 B 17235 B
./infiniteexperimental"Load more" over ordinary page entriesinfinite (marginal) 1648 B 1502 B
./liveexperimentalSocket events that invalidate or patch cached entrieslive (marginal) 2777 B 2565 B
./tabsexperimentalOne poller and one socket per browser, shared by every tabtabs (marginal) 3104 B 2874 B
./persistexperimentalOpt-in IndexedDB persistence per endpoint and per sessionpersist (marginal) 3428 B 3201 B
./ssrexperimentalSvelteKit server rendering, dehydrate and hydrate, load invalidationssr (marginal)ssr every export (marginal) 568 B1287 B 514 B1178 B
./devtoolsexperimentalDevelopment detectors, a timeline and an in-page panel; a stub in productiondevtools (production stub) 92 B 68 B
query-codegenexperimentalA development tool: OpenAPI 3.x document to endpoint modulesa development tool; nothing ships to the browser

Consumer entries bundled with Vite in production mode (Rollup tree-shaking, esbuild minify, ESM, es2022, production condition, svelte external, .svelte.js compiled with compileModule). "query" = defineQuery + createQuery + client (first-load code; optional parts are their own "part:" rows, loaded on first use); "query+mutation" adds defineMutation, createMutation, patch; "query + every part (static)" adds `allParts` from `./parts` (every optional part in the first load, no lazy chunk). Vite's preload helper is the application's and not counted. Marginal rows subtract the row named in "over". gzip level 9 / brotli quality 11 (node:zlib). Budgets are gzip; "target" is the 04 §2 target. Budgets, targets and every other measurement: Performance.

Where to go next