sveltelean Query
Version v0.2.0

Names

TanStack QuerySvelte Lean Query
queryOptions + key factorydefineQuery
staleTime / gcTimefresh / keep
enabledparams return undefined (input), when (state), access (rights)
placeholderData: keepPreviousDatakeepPrevious: true
isPending, isFetching and the other flagssee States, field by field
refetchIntervalpoll
invalidateQueriesinvalidate, invalidates
getQueryData / setQueryDataep.get / ep.set, patch
enabled: false + refetch()when: false + load()
fetchQuery / prefetchQueryep.fetch / ep.prefetch
mutate, isPendingrun, busy
onMutate + rollbackoptimistic: (input) => patch(…)
createInfiniteQuerycreateInfinite
QueryClientProvidersetClient(createClient(…))

States, field by field

TanStack Query names its flags after the request: is one running, is it the first. Svelte Lean Query adds a second pair, loading and refreshing, named after the user: does the user wait for this request. The fields of a binding (q), read one by one:

TanStack QuerySvelte Lean QueryMeaning
status: 'pending' | 'error' | 'success'info.statusAdds 'idle' for a query paused by when, missing params or a refused access rule.
isPendinginfo.status === 'pending' && !info.placeholderNothing to show yet.
isLoadinginfo.status === 'pending' && !info.placeholder && busyisPending while the first request runs.
isFetching, fetchStatus === 'fetching'busyAny request in flight.
isRefetchingbusy && data !== undefinedA request while data is shown.
isPlaceholderDatainfo.placeholderThe shown data is the previous key’s (keepPrevious) or the placeholder option’s.
placeholderData: keepPreviousDatakeepPrevious: trueOption of createQuery.
placeholderData: valueplaceholder: valueOption of createQuery; shown while pending, never cached.
isStaleinfo.stalePast fresh, invalidated, or never answered.
dataUpdatedAtinfo.updatedAt0 before the first success.
isError, errorerrorA QueryError with kind, status, code and fields.

loading or refreshing

  • loading: the user waits. The first load, new params (a filter, a page), and refetch().
  • refreshing: a quiet request while this key's data is shown. A poll, an invalidation after a write, a refetch when the tab gets focus again.
  • busy is loading || refreshing; info.fetch.cause says why the request was sent: 'mount', 'params', 'poll', 'user', 'invalidate', 'focus', …
  • A call can choose: refetch({ loading: false }) refreshes without a loader. createClient({ defaults: { loadingDelay } }) keeps a short loading fetch, with data on screen, as refreshing until the delay passes, so a fast filter change does not flash a loader.

The flags of TanStack Query

Svelte Lean Query
const members = createQuery(listMembers, () => filters, {
	keepPrevious: true, // the last rows stay on screen while a new filter loads
	poll: () => '30s'
});

// Nothing to show yet: RTK isLoading, TanStack isPending.
const firstLoad = $derived(members.info.status === 'pending' && !members.info.placeholder);
// Any request in flight, the first one included: isFetching.
const fetching = $derived(members.busy);
// The rows on screen belong to the previous filter: TanStack isPlaceholderData.
const previousRows = $derived(members.info.placeholder);
// RTK currentData: the data of the current filter only.
const currentData = $derived(members.info.placeholder ? undefined : members.data);
// Why the running (or last) request was sent: 'mount', 'params', 'poll', 'user', 'invalidate', …
const cause = $derived(members.info.fetch?.cause);

One list, four kinds of request

The first load shows a loading text; a filter change or the Refresh button a loader over the previous rows, dimmed; the poll every 30 seconds a thin bar only.

Svelte Lean Query
<input type="search" aria-label="Search members" bind:value={filters.search} />
<button type="button" onclick={() => members.refetch()}>Refresh</button>

{#if firstLoad}
	<p>Loading…</p>
{:else}
	<!-- loading: the user waits (filter change, Refresh); refreshing: a poll or an invalidation -->
	{#if members.loading}<progress aria-label="Loading"></progress>{/if}
	{#if members.refreshing}<progress class="thin" aria-label="Refreshing"></progress>{/if}
	<ul class:dimmed={members.info.placeholder}>
		{#each members.data?.rows ?? [] as row (row.id)}<li>{row.name}</li>{/each}
	</ul>
{/if}
{#if members.error}<p role="alert">{members.error.message}</p>{/if}

Setup

Client

No provider component: the client is created once and found through context.

TanStack Query
const queryClient = new QueryClient();
// +layout.svelte
<QueryClientProvider client={queryClient}>
	{@render children()}
</QueryClientProvider>
Svelte Lean Query
createClient({ transport: http({ base: '/' }) });

Definitions

Query options and keys

The endpoint and its params are the key; there is no key factory to keep in sync.

TanStack Query
export const memberKeys = {
	all: ['members'] as const,
	list: (filters: MemberFilters) => [...memberKeys.all, 'list', filters] as const
};
export const listMembers = (filters: MemberFilters) =>
	queryOptions({
		queryKey: memberKeys.list(filters),
		queryFn: () => api.get<Page<Member>>('api/members', filters),
		staleTime: 15_000,
		gcTime: 300_000
	});
Svelte Lean Query
const listMembers = defineQuery({
	get: 'api/members',
	params: type<MemberFilters>(),
	result: page<Member>(),
	tags: ['Members'],
	fresh: '15s', // staleTime
	keep: '5m' // gcTime
});

A detail query

TanStack Query
export const getMember = (id: number) =>
	queryOptions({
		queryKey: ['members', 'detail', id],
		queryFn: () => api.get<Member>(`api/members/${id}`)
	});
Svelte Lean Query
const getMember = defineQuery({
	get: 'api/members/{id}',
	result: type<Member>(),
	entity: 'Member' // provides tag('Member', id)
});

A mutation and what it invalidates

Invalidation moves from onSuccess callbacks to the definition, as tags.

TanStack Query
const queryClient = useQueryClient();
const updateMember = createMutation(() => ({
	mutationFn: (input: { id: number; name: string }) =>
		api.patch<Member>(`api/members/${input.id}`, input),
	onSuccess: (member) => {
		queryClient.invalidateQueries({ queryKey: ['members', 'detail', member.id] });
		queryClient.invalidateQueries({ queryKey: ['members', 'list'] });
	}
}));
Svelte Lean Query
const updateMember = defineMutation({
	patch: 'api/members/{id}',
	body: type<{ name: string }>(),
	result: type<Member>(),
	invalidates: (member) => [tag('Member', member.id), 'Members']
});

Reading

Read

TanStack Query
const members = createQuery(() => listMembers(filters));
Svelte Lean Query
const members = createQuery(listMembers, () => filters);

Loading states

loading is a fetch the user waits for; polls, invalidations and focus refetches are refreshing.

TanStack Query
{#if members.isPending}<p>Loading…</p>{/if}
{#if members.isFetching && !members.isPending}<progress></progress>{/if}
{#if members.isError}<p role="alert">{members.error.message}</p>{/if}
Svelte Lean Query
{#if members.loading}<p>Loading…</p>{/if}
{#if members.refreshing}<progress aria-label="Refreshing"></progress>{/if}
{#if members.error}<p role="alert">{members.error.message}</p>{/if}

enabled: a missing input

TanStack Query
const member = createQuery(() => ({
	...getMember(id!),
	enabled: id !== undefined
}));
Svelte Lean Query
const member = createQuery(getMember, () => (id ? { id } : undefined));

enabled: a state condition

when pauses and keeps the data; access rights belong on the definition as access.

TanStack Query
const details = createQuery(() => ({ ...getMember(1), enabled: open }));
Svelte Lean Query
const details = createQuery(getMember, () => ({ id: 1 }), { when: () => open });

Keep the previous data

TanStack Query
const list = createQuery(() => ({
	...listMembers(filters),
	placeholderData: keepPreviousData
}));
Svelte Lean Query
const list = createQuery(listMembers, () => filters, { keepPrevious: true });

Polling

One scheduler timer per client; a poll is skipped while the tab is hidden.

TanStack Query
const polled = createQuery(() => ({
	...listMembers(filters),
	refetchInterval: 30_000
}));
Svelte Lean Query
const polled = createQuery(listMembers, () => filters, { poll: () => '30s' });

A query started by an event

TanStack Query
let term = $state<string | null>(null);
const search = createQuery(() => ({
	...listMembers({ search: term ?? '' }),
	enabled: term !== null
}));
// on submit: term = input.value
Svelte Lean Query
const search = createQuery(listMembers, { when: false });
// later: await search.load({ search: term });

Cache

Invalidate

TanStack Query
queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['members', 'detail', id] });
Svelte Lean Query
invalidate('Members'); // every entry tagged Members
invalidate(tag('Member', id)); // one member, everywhere it is cached

Read and write cached data

TanStack Query
const cached = queryClient.getQueryData<Member>(['members', 'detail', id]);
queryClient.setQueryData<Member>(['members', 'detail', id], (old) =>
	old ? { ...old, name: 'Amara' } : old
);
Svelte Lean Query
const cached = getMember.get({ id });
getMember.set({ id }, (previous) => ({ ...previous!, name: 'Amara' }));

Update one row everywhere

entity on the definition tags each row; patch finds them through the tag index.

TanStack Query
// every cached list page that holds the row
queryClient.setQueriesData<Page<Member>>({ queryKey: ['members', 'list'] }, (page) =>
	page && {
		...page,
		rows: page.rows.map((m) => (m.id === id ? { ...m, pinned: true } : m))
	}
);
Svelte Lean Query
// Every page and detail that holds the row, at once (undefined: no change, null: removed).
patch(tag('Member', id), (m: Member) => ({ ...m, pinned: true }));

Prefetch

TanStack Query
queryClient.prefetchQuery(getMember(id));
Svelte Lean Query
getMember.prefetch({ id }); // low priority; errors stay on the entry

Fetch outside a component

TanStack Query
const member = await queryClient.fetchQuery(getMember(id));
Svelte Lean Query
const member = await getMember.fetch({ id }); // fresh cache, else one request

Writing

Run a mutation

run() returns a promise and writes busy, data and error; a second run while busy returns the first.

TanStack Query
const save = createMutation(() => ({ mutationFn: patchMember }));
// save.mutate({ id: 1, name: 'Amara' }); save.isPending; save.error
Svelte Lean Query
const save = createMutation(updateMember);
// save.run({ id: 1, name: 'Amara' }); save.busy; save.error

React to success

TanStack Query
const save = createMutation(() => ({
	mutationFn: patchMember,
	onSuccess: (member) => toast(`Saved ${member.name}`)
}));
Svelte Lean Query
const save = createMutation(updateMember, {
	onSuccess: (member) => console.info('saved', member.name) // a toast, close a dialog
});

Optimistic update

optimistic calls patch(): no cancel, no snapshot, no restore; a failure removes only its own layer.

TanStack Query
const pin = createMutation(() => ({
	mutationFn: pinMember,
	onMutate: async ({ id, pinned }) => {
		await queryClient.cancelQueries({ queryKey: ['members', 'list'] });
		const previous = queryClient.getQueriesData<Page<Member>>({ queryKey: ['members', 'list'] });
		queryClient.setQueriesData<Page<Member>>({ queryKey: ['members', 'list'] }, (page) =>
			page && { ...page, rows: page.rows.map((m) => (m.id === id ? { ...m, pinned } : m)) }
		);
		return { previous };
	},
	onError: (_error, _input, context) => {
		for (const [key, data] of context?.previous ?? []) queryClient.setQueryData(key, data);
	},
	onSettled: () => queryClient.invalidateQueries({ queryKey: ['members', 'list'] })
}));
Svelte Lean Query
const pin = createMutation(pinMember, {
	// A patch made here is undone if the write fails.
	optimistic: ({ id, pinned }) => patch(tag('Member', id), (m: Member) => ({ ...m, pinned }))
});

Lists

Infinite list

TanStack Query
const feed = createInfiniteQuery(() => ({
	queryKey: ['members', 'feed', filters],
	queryFn: ({ pageParam }) => api.get<Page<Member>>('api/members', { ...filters, page: pageParam }),
	initialPageParam: 1,
	getNextPageParam: (last) => (last.page < last.lastPage ? last.page + 1 : undefined)
}));
// feed.data?.pages.flatMap((p) => p.rows), feed.hasNextPage, feed.fetchNextPage()
Svelte Lean Query
const feed = createInfinite(listMembers, () => filters, { perPage: 50 });
// feed.rows, feed.hasMore, feed.loadMore(), feed.loadingMore

Server rendering

Prefetch in a load

TanStack Query
// src/routes/members/+page.ts (queryClient from the layout load, enabled: browser)
export const load: PageLoad = async ({ parent }) => {
	const { queryClient } = await parent();
	await queryClient.prefetchQuery(listMembers({}));
};
Svelte Lean Query
// src/routes/members/+page.ts — the layout load created one client per request (see Server rendering)
export const load: Load = async ({ parent }) => {
	const { client } = await parent();
	await listMembers.fetch({}, { client }); // rendered on the server, replayed in the browser
};

Tooling

Devtools

TanStack Query
<script lang="ts">
	import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
</script>

<SvelteQueryDevtools />
Svelte Lean Query
createClient({
	transport: http({ base: '/' }),
	modules: [devtools({ panel: true })] // a stub in production builds
});

Tests

A test client with a virtual clock and typed mocks; MSW handlers come from the same definitions.

TanStack Query
const queryClient = new QueryClient({
	defaultOptions: { queries: { retry: false } }
});
server.use(http.get('*/api/members', () => HttpResponse.json(emptyPage)));
render(WithQueryClient, { props: { client: queryClient } }); // <QueryClientProvider> + <MemberList />
await screen.findByText('No members');
Svelte Lean Query
using t = createTestClient(); // virtual clock, devtools findings on
t.mock(listMembers, { rows: [], total: 0, page: 1, perPage: 20, lastPage: 1 }, { latency: 50 });
t.mock(updateMember, () => reply.validation({ email: 'taken' }));
const list = createQuery(listMembers, () => ({}));
t.observe(() => list.data);
await t.advance(50);
// expect(t.calls(listMembers)).toHaveLength(1)

Concepts that do not map one to one

  • Tags, not key prefixes. An entry carries declared tags; a write names the tags it touches. A typo in a tag is a compile error.
  • Access on the definition. A permission check is part of the endpoint, so no screen sends a request the user may not make, and cached data is removed when access is lost.
  • loading and refreshing. Decided by why a fetch runs, so a poll never shows a skeleton.
  • Layers, not snapshots. A failed write removes only its own change, even when newer data arrived meanwhile.
  • Scope. createClient({ scope }) prefixes every key with the tenant or user once, instead of in every key factory.

Order of a migration

  1. The platform layer, one file each: the client, the transport, the profile, the scope and the permission check (Client setup).
  2. Shared helpers: paged lists, pollers, optimistic toggles and form errors switch to createPaged, poll, optimistic and fieldErrors. Meanwhile one bridge file keeps both caches coherent: an invalidate(tag) also invalidates the TanStack keys of that tag, and the reverse.
  3. Features, smallest first: query and mutation factories become one module of definitions per feature; enabled becomes access, when, params that return undefined or load(); busy flags, rollback closures and refresh helpers are deleted.
  4. Remove TanStack Query and the bridge, then measure the script size of each section.