Query Migration
From TanStack Query
TanStack Query next to Svelte Lean Query, topic by topic. Most names map one to one; keys become endpoints and invalidation moves to tags on the definitions.
Markdown version of this page, for assistants (Markdown for AI)
On this page
Names
| TanStack Query | Svelte Lean Query |
|---|---|
queryOptions + key factory | defineQuery |
staleTime / gcTime | fresh / keep |
enabled | params return undefined (input), when (state), access (rights) |
placeholderData: keepPreviousData | keepPrevious: true |
isPending, isFetching and the other flags | see States, field by field |
refetchInterval | poll |
invalidateQueries | invalidate, invalidates |
getQueryData / setQueryData | ep.get / ep.set, patch |
enabled: false + refetch() | when: false + load() |
fetchQuery / prefetchQuery | ep.fetch / ep.prefetch |
mutate, isPending | run, busy |
onMutate + rollback | optimistic: (input) => patch(…) |
createInfiniteQuery | createInfinite |
QueryClientProvider | setClient(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 Query | Svelte Lean Query | Meaning |
|---|---|---|
status: 'pending' | 'error' | 'success' | info.status | Adds 'idle' for a query paused by when, missing params or a refused access rule. |
isPending | info.status === 'pending' && !info.placeholder | Nothing to show yet. |
isLoading | info.status === 'pending' && !info.placeholder && busy | isPending while the first request runs. |
isFetching, fetchStatus === 'fetching' | busy | Any request in flight. |
isRefetching | busy && data !== undefined | A request while data is shown. |
isPlaceholderData | info.placeholder | The shown data is the previous key’s (keepPrevious) or the placeholder option’s. |
placeholderData: keepPreviousData | keepPrevious: true | Option of createQuery. |
placeholderData: value | placeholder: value | Option of createQuery; shown while pending, never cached. |
isStale | info.stale | Past fresh, invalidated, or never answered. |
dataUpdatedAt | info.updatedAt | 0 before the first success. |
isError, error | error | A QueryError with kind, status, code and fields. |
loading or refreshing
loading: the user waits. The first load, new params (a filter, a page), andrefetch().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.busyisloading || refreshing;info.fetch.causesays 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, asrefreshinguntil the delay passes, so a fast filter change does not flash a loader.
The flags of TanStack 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.
<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.
const queryClient = new QueryClient();
// +layout.svelte
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>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.
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
});const listMembers = defineQuery({
get: 'api/members',
params: type<MemberFilters>(),
result: page<Member>(),
tags: ['Members'],
fresh: '15s', // staleTime
keep: '5m' // gcTime
});A detail query
export const getMember = (id: number) =>
queryOptions({
queryKey: ['members', 'detail', id],
queryFn: () => api.get<Member>(`api/members/${id}`)
});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.
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'] });
}
}));const updateMember = defineMutation({
patch: 'api/members/{id}',
body: type<{ name: string }>(),
result: type<Member>(),
invalidates: (member) => [tag('Member', member.id), 'Members']
});Reading
Read
const members = createQuery(() => listMembers(filters));const members = createQuery(listMembers, () => filters);Loading states
loading is a fetch the user waits for; polls, invalidations and focus refetches are refreshing.
{#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}{#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
const member = createQuery(() => ({
...getMember(id!),
enabled: id !== undefined
}));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.
const details = createQuery(() => ({ ...getMember(1), enabled: open }));const details = createQuery(getMember, () => ({ id: 1 }), { when: () => open });Keep the previous data
const list = createQuery(() => ({
...listMembers(filters),
placeholderData: keepPreviousData
}));const list = createQuery(listMembers, () => filters, { keepPrevious: true });Polling
One scheduler timer per client; a poll is skipped while the tab is hidden.
const polled = createQuery(() => ({
...listMembers(filters),
refetchInterval: 30_000
}));const polled = createQuery(listMembers, () => filters, { poll: () => '30s' });A query started by an event
let term = $state<string | null>(null);
const search = createQuery(() => ({
...listMembers({ search: term ?? '' }),
enabled: term !== null
}));
// on submit: term = input.valueconst search = createQuery(listMembers, { when: false });
// later: await search.load({ search: term });Cache
Invalidate
queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['members', 'detail', id] });invalidate('Members'); // every entry tagged Members
invalidate(tag('Member', id)); // one member, everywhere it is cachedRead and write cached data
const cached = queryClient.getQueryData<Member>(['members', 'detail', id]);
queryClient.setQueryData<Member>(['members', 'detail', id], (old) =>
old ? { ...old, name: 'Amara' } : old
);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.
// 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))
}
);// 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
queryClient.prefetchQuery(getMember(id));getMember.prefetch({ id }); // low priority; errors stay on the entryFetch outside a component
const member = await queryClient.fetchQuery(getMember(id));const member = await getMember.fetch({ id }); // fresh cache, else one requestWriting
Run a mutation
run() returns a promise and writes busy, data and error; a second run while busy returns the first.
const save = createMutation(() => ({ mutationFn: patchMember }));
// save.mutate({ id: 1, name: 'Amara' }); save.isPending; save.errorconst save = createMutation(updateMember);
// save.run({ id: 1, name: 'Amara' }); save.busy; save.errorReact to success
const save = createMutation(() => ({
mutationFn: patchMember,
onSuccess: (member) => toast(`Saved ${member.name}`)
}));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.
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'] })
}));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
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()const feed = createInfinite(listMembers, () => filters, { perPage: 50 });
// feed.rows, feed.hasMore, feed.loadMore(), feed.loadingMoreServer rendering
Prefetch in a load
// 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({}));
};// 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
<script lang="ts">
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
</script>
<SvelteQueryDevtools />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.
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');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
- The platform layer, one file each: the client, the transport, the profile, the scope and the permission check (Client setup).
- Shared helpers: paged lists, pollers, optimistic toggles and form errors switch to
createPaged,poll,optimisticandfieldErrors. Meanwhile one bridge file keeps both caches coherent: aninvalidate(tag)also invalidates the TanStack keys of that tag, and the reverse. - Features, smallest first: query and mutation factories become one module of definitions per
feature;
enabledbecomesaccess,when, params that returnundefinedorload(); busy flags, rollback closures and refresh helpers are deleted. - Remove TanStack Query and the bridge, then measure the script size of each section.