# From TanStack Query

> TanStack Query concepts next to their Svelte Lean Query equivalents: keys, options, enabled, placeholders, invalidation, mutations and optimistic updates.

Page: https://svelte-lean.vercel.app/query/migration/tanstack-query · Markdown: https://svelte-lean.vercel.app/query/migration/tanstack-query.md · Index: https://svelte-lean.vercel.app/llms.txt

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.

## 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), 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`

```ts
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`

```svelte
<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`

```svelte
const queryClient = new QueryClient();
// +layout.svelte
<QueryClientProvider client={queryClient}>
	{@render children()}
</QueryClientProvider>
```

`Svelte Lean Query`

```ts
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`

```ts
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`

```ts
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`

```ts
export const getMember = (id: number) =>
	queryOptions({
		queryKey: ['members', 'detail', id],
		queryFn: () => api.get<Member>(`api/members/${id}`)
	});
```

`Svelte Lean Query`

```ts
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`

```ts
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`

```ts
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`

```ts
const members = createQuery(() => listMembers(filters));
```

`Svelte Lean Query`

```ts
const members = createQuery(listMembers, () => filters);
```

### Loading states

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

`TanStack Query`

```svelte
{#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`

```svelte
{#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`

```ts
const member = createQuery(() => ({
	...getMember(id!),
	enabled: id !== undefined
}));
```

`Svelte Lean Query`

```ts
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`

```ts
const details = createQuery(() => ({ ...getMember(1), enabled: open }));
```

`Svelte Lean Query`

```ts
const details = createQuery(getMember, () => ({ id: 1 }), { when: () => open });
```

### Keep the previous data

`TanStack Query`

```ts
const list = createQuery(() => ({
	...listMembers(filters),
	placeholderData: keepPreviousData
}));
```

`Svelte Lean Query`

```ts
const list = createQuery(listMembers, () => filters, { keepPrevious: true });
```

### Polling

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

`TanStack Query`

```ts
const polled = createQuery(() => ({
	...listMembers(filters),
	refetchInterval: 30_000
}));
```

`Svelte Lean Query`

```ts
const polled = createQuery(listMembers, () => filters, { poll: () => '30s' });
```

### A query started by an event

`TanStack Query`

```ts
let term = $state<string | null>(null);
const search = createQuery(() => ({
	...listMembers({ search: term ?? '' }),
	enabled: term !== null
}));
// on submit: term = input.value
```

`Svelte Lean Query`

```ts
const search = createQuery(listMembers, { when: false });
// later: await search.load({ search: term });
```

## Cache

### Invalidate

`TanStack Query`

```ts
queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['members', 'detail', id] });
```

`Svelte Lean Query`

```ts
invalidate('Members'); // every entry tagged Members
invalidate(tag('Member', id)); // one member, everywhere it is cached
```

### Read and write cached data

`TanStack Query`

```ts
const cached = queryClient.getQueryData<Member>(['members', 'detail', id]);
queryClient.setQueryData<Member>(['members', 'detail', id], (old) =>
	old ? { ...old, name: 'Amara' } : old
);
```

`Svelte Lean Query`

```ts
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`

```ts
// 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`

```ts
// 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`

```ts
queryClient.prefetchQuery(getMember(id));
```

`Svelte Lean Query`

```ts
getMember.prefetch({ id }); // low priority; errors stay on the entry
```

### Fetch outside a component

`TanStack Query`

```ts
const member = await queryClient.fetchQuery(getMember(id));
```

`Svelte Lean Query`

```ts
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`

```ts
const save = createMutation(() => ({ mutationFn: patchMember }));
// save.mutate({ id: 1, name: 'Amara' }); save.isPending; save.error
```

`Svelte Lean Query`

```ts
const save = createMutation(updateMember);
// save.run({ id: 1, name: 'Amara' }); save.busy; save.error
```

### React to success

`TanStack Query`

```ts
const save = createMutation(() => ({
	mutationFn: patchMember,
	onSuccess: (member) => toast(`Saved ${member.name}`)
}));
```

`Svelte Lean Query`

```ts
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`

```ts
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`

```ts
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`

```ts
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`

```ts
const feed = createInfinite(listMembers, () => filters, { perPage: 50 });
// feed.rows, feed.hasMore, feed.loadMore(), feed.loadingMore
```

## Server rendering

### Prefetch in a load

`TanStack Query`

```ts
// 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`

```ts
// 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`

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

<SvelteQueryDevtools />
```

`Svelte Lean Query`

```ts
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`

```ts
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`

```ts
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](https://svelte-lean.vercel.app/query/client.md)).
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.
