Svelte Lean Query
Server state for Svelte 5: endpoints defined once, a cache keyed by params, invalidation by typed tags, and the loading states a screen needs, built on runes.
Markdown version of this page, for assistants (Markdown for AI)
On this page
At a glance
Search, then pin a member. The panel lists what the server received and why: mount, params, mutation, invalidate.
The example runs in the browser.
Requests the server received
- 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>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 — 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, neverloading. - Pinning refetches only the observed entries tagged
Member:<id>, once, even when a poll lands at the same time. - Without the
members.viewpermission, 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/querypnpm add @svelte-lean/queryyarn add @svelte-lean/querybun add @svelte-lean/queryLevels
A screen should need level 1 only. The guides and the reference follow the same order.
| Level | Names | Used for | Guides |
|---|---|---|---|
| 1 · every day | defineQuerydefineMutationcreateQuerycreateMutationrundataloadingrefreshingerrorbusy | Most screens | |
| 2 · often | keepPreviouspollwhenload()invalidate()patch()optimisticcreatePagedcreateInfiniteallowed() | Tables, dialogs, forms | |
| 3 · advanced | info{ loading }reasonentitydefineModulesend()partslivepersist | Complex pages, realtime |
When a query runs
A query starts when its result is read on screen. Everything else narrows that.
| Need | How | Recipe |
|---|---|---|
| It should load when shown | nothing: read it inside {#if} | Load when a dialog opens |
| A state condition: dialog, tab | when: () => open | Reading |
| A required input is missing | params return undefined | Dependent 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 point | Stability | Contains | Measured | gzip | brotli |
|---|---|---|---|---|---|
@svelte-lean/query | stable target | Definitions, the client, createQuery, createMutation, invalidate, patch, errors | queryquery+mutationroot (every export) | 11787 B15409 B16134 B | 10839 B14167 B14844 B |
./http | stable target | The fetch transport: base, headers, timeout, typed errors, uploads, downloads | http (marginal) | 2239 B | 2079 B |
./laravel | stable target | A platform profile: envelope, refusals, field errors, paginators, query strings | laravel (marginal) | 1681 B | 1548 B |
./paged | stable target | Server-paged lists and the props of <DataTable manual> | paged (marginal) | 1234 B | 1109 B |
./forms | stable target | Server field errors into native form state | forms (marginal) | 1406 B | 1226 B |
./parts | stable target | Optional engine parts included up front with createClient({ parts }) | every part (static, marginal)query + every part (static) | 3880 B15667 B | 3544 B14383 B |
./testing | stable target | A test client with virtual time and typed mocks; MSW handlers | testing (not shipped) | 18878 B | 17235 B |
./infinite | experimental | "Load more" over ordinary page entries | infinite (marginal) | 1648 B | 1502 B |
./live | experimental | Socket events that invalidate or patch cached entries | live (marginal) | 2777 B | 2565 B |
./tabs | experimental | One poller and one socket per browser, shared by every tab | tabs (marginal) | 3104 B | 2874 B |
./persist | experimental | Opt-in IndexedDB persistence per endpoint and per session | persist (marginal) | 3428 B | 3201 B |
./ssr | experimental | SvelteKit server rendering, dehydrate and hydrate, load invalidation | ssr (marginal)ssr every export (marginal) | 568 B1287 B | 514 B1178 B |
./devtools | experimental | Development detectors, a timeline and an in-page panel; a stub in production | devtools (production stub) | 92 B | 68 B |
query-codegen | experimental | A development tool: OpenAPI 3.x document to endpoint modules | a 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
- Client setup, then Defining endpoints.
- Coming from another library: TanStack Query or RTK Query.
- Task by task: Recipes.