# From RTK Query

> RTK Query concepts next to their Svelte Lean Query equivalents: createApi, endpoints, providesTags, invalidatesTags, lazy queries, polling and optimistic updates.

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

RTK Query next to Svelte Lean Query, topic by topic. Tags work the same way; the store, the generated hooks and the single API object go away.

## Names

| RTK Query | Svelte Lean Query |
| --- | --- |
| `createApi` + `fetchBaseQuery` | `createClient` + `http()` |
| `build.query` / `build.mutation` | `defineQuery` / `defineMutation` |
| `tagTypes` | the `Tags` registry |
| `providesTags` / `invalidatesTags` | `tags`, `entity` / `invalidates` |
| `useXQuery(arg)` | `createQuery(endpoint, () => params)` |
| `skipToken`, `skip` | params return `undefined`, `when` |
| `useLazyXQuery` | `{ when: false }` + `load()` |
| `pollingInterval` | `poll` |
| `keepUnusedDataFor` | `keep` |
| `refetchOnMountOrArgChange` | `fresh` |
| `isLoading`, `isFetching` and the other flags | see States, field by field |
| `updateQueryData` | `ep.set`, `patch`, `optimistic` |
| `selectFromResult` | `$derived` over `data` |

## States, field by field

RTK 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:

| RTK Query | Svelte Lean Query | Meaning |
| --- | --- | --- |
| `isUninitialized` | `info.status === 'idle'` | Params return undefined, when is false, or the access rule refuses. |
| `isLoading` | `info.status === 'pending' && !info.placeholder` | Nothing to show yet: the first load of the binding. |
| `isFetching` | `busy` | Any request in flight, the first one included. |
| `isSuccess` | `info.status === 'success'` | The current key has data. |
| `isError, error` | `error` | A QueryError with kind, status, code and fields. |
| `data` | `data with keepPrevious: true` | RTK keeps the last result across arguments; keepPrevious does the same. |
| `currentData` | `info.placeholder ? undefined : data` | Only the data of the current params. |
| `fulfilledTimeStamp` | `info.updatedAt` | 0 before the first success. |
| `refetch()` | `refetch()` | Shown as loading; refetch({ loading: false }) keeps it quiet. |

### 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 RTK 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

### API slice and store

No store and no middleware: one client with a transport; tag names live in a typed registry.

`RTK Query`

```ts
export const api = createApi({
	baseQuery: fetchBaseQuery({ baseUrl: '/' }),
	tagTypes: ['Member', 'Members'],
	endpoints: () => ({})
});
// store.ts: reducer: { [api.reducerPath]: api.reducer }, middleware: (gDM) => gDM().concat(api.middleware)
```

`Svelte Lean Query`

```ts
createClient({ transport: http({ base: '/' }) });
```

## Definitions

### A query endpoint and its tags

Endpoints are plain exported values, not members of one API object: a module that imports two endpoints ships two.

`RTK Query`

```ts
const membersApi = api.injectEndpoints({
	endpoints: (build) => ({
		listMembers: build.query<Page<Member>, MemberFilters>({
			query: (filters) => ({ url: 'api/members', params: filters }),
			providesTags: (page) => [
				'Members',
				...(page?.rows.map((m) => ({ type: 'Member' as const, id: m.id })) ?? [])
			],
			keepUnusedDataFor: 300
		})
	})
});
export const { useListMembersQuery } = membersApi;
```

`Svelte Lean Query`

```ts
const listMembers = defineQuery({
	get: 'api/members',
	params: type<MemberFilters>(),
	result: page<Member>(),
	tags: ['Members'],
	fresh: '15s', // staleTime
	keep: '5m' // gcTime
});
```

### providesTags per id

`RTK Query`

```ts
getMember: build.query<Member, number>({
	query: (id) => `api/members/${id}`,
	providesTags: (_member, _error, id) => [{ type: 'Member', id }]
})
```

`Svelte Lean Query`

```ts
const getMember = defineQuery({
	get: 'api/members/{id}',
	result: type<Member>(),
	entity: 'Member' // provides tag('Member', id)
});
```

### invalidatesTags

`RTK Query`

```ts
updateMember: build.mutation<Member, { id: number; name: string }>({
	query: ({ id, ...body }) => ({ url: `api/members/${id}`, method: 'PATCH', body }),
	invalidatesTags: (member) => (member ? [{ type: 'Member', id: member.id }, 'Members'] : [])
})
```

`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

### The generated hook

No generated names: createQuery takes the endpoint.

`RTK Query`

```ts
const { data, isLoading, isFetching, error } = useListMembersQuery(filters);
```

`Svelte Lean Query`

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

### Loading states

`RTK Query`

```ts
if (isLoading) return <p>Loading…</p>;
if (error) return <p role="alert">{String(error)}</p>;
// isFetching && !isLoading: a background refetch
```

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

### skipToken

`RTK Query`

```ts
const member = useGetMemberQuery(id ?? skipToken);
```

`Svelte Lean Query`

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

### The skip option

when pauses and keeps the data; it never deletes it.

`RTK Query`

```ts
const details = useGetMemberQuery(1, { skip: !open });
```

`Svelte Lean Query`

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

### Previous data while the arg changes

`RTK Query`

```ts
// The hook keeps the last result of another arg in `data`;
// `currentData` holds only this arg's result.
const { data, currentData, isFetching } = useListMembersQuery(filters);
```

`Svelte Lean Query`

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

### pollingInterval

The shortest active interval wins; the deadlines do not drift with latency.

`RTK Query`

```ts
const polled = useListMembersQuery(filters, {
	pollingInterval: 30_000,
	skipPollingIfUnfocused: true
});
```

`Svelte Lean Query`

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

### Lazy query

After load() the binding is an ordinary query: no second subscription model.

`RTK Query`

```ts
const [trigger, search] = useLazyListMembersQuery();
// on submit: trigger({ search: term });
```

`Svelte Lean Query`

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

## Cache

### invalidateTags

`RTK Query`

```ts
dispatch(api.util.invalidateTags(['Members']));
dispatch(api.util.invalidateTags([{ type: 'Member', id }]));
```

`Svelte Lean Query`

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

### select and updateQueryData

`RTK Query`

```ts
const cached = membersApi.endpoints.getMember.select(id)(getState()).data;
dispatch(
	membersApi.util.updateQueryData('getMember', id, (draft) => {
		draft.name = 'Amara';
	})
);
```

`Svelte Lean Query`

```ts
const cached = getMember.get({ id });
getMember.set({ id }, (previous) => ({ ...previous!, name: 'Amara' }));
```

### Update one row in every cached list

One call; rows are found through the tag index, not by walking the cached args.

`RTK Query`

```ts
// one dispatch per cached list arg that holds the row
for (const args of membersApi.util.selectCachedArgsForQuery(getState(), 'listMembers')) {
	dispatch(
		membersApi.util.updateQueryData('listMembers', args, (page) => {
			const row = page.rows.find((m) => m.id === id);
			if (row) row.pinned = true;
		})
	);
}
```

`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 }));
```

### usePrefetch

`RTK Query`

```ts
const prefetchMember = usePrefetch('getMember');
// onMouseEnter={() => prefetchMember(id)}
```

`Svelte Lean Query`

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

### initiate

`RTK Query`

```ts
const member = await dispatch(membersApi.endpoints.getMember.initiate(id)).unwrap();
```

`Svelte Lean Query`

```ts
const member = await getMember.fetch({ id }); // fresh cache, else one request
```

## Writing

### The mutation hook

`RTK Query`

```ts
const [updateMember, { isLoading, error }] = useUpdateMemberMutation();
// await updateMember({ id: 1, name: 'Amara' }).unwrap();
```

`Svelte Lean Query`

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

### unwrap and react

`RTK Query`

```ts
const [updateMember] = useUpdateMemberMutation();
const member = await updateMember(input).unwrap();
toast(`Saved ${member.name}`);
```

`Svelte Lean Query`

```ts
const save = createMutation(updateMember, {
	onSuccess: (member) => console.info('saved', member.name) // a toast, close a dialog
});
```

### onQueryStarted and patch.undo()

An optimistic patch is a layer removed on failure, without replaying an inverse patch over newer data.

`RTK Query`

```ts
pinMember: build.mutation<Member, { id: number; pinned: boolean }>({
	query: ({ id, pinned }) => ({ url: `api/members/${id}/pin`, method: 'PUT', body: { pinned } }),
	async onQueryStarted({ id, pinned }, { dispatch, queryFulfilled }) {
		const patch = dispatch(
			membersApi.util.updateQueryData('getMember', id, (draft) => {
				draft.pinned = pinned;
			})
		);
		try {
			await queryFulfilled;
		} catch {
			patch.undo();
		}
	}
})
```

`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

### infiniteQuery

`RTK Query`

```ts
listFeed: build.infiniteQuery<Page<Member>, MemberFilters, number>({
	infiniteQueryOptions: {
		initialPageParam: 1,
		getNextPageParam: (last) => (last.page < last.lastPage ? last.page + 1 : undefined)
	},
	query: ({ queryArg, pageParam }) => ({ url: 'api/members', params: { ...queryArg, page: pageParam } })
})
// const { data, hasNextPage, fetchNextPage } = useListFeedInfiniteQuery(filters);
```

`Svelte Lean Query`

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

## Server rendering

### Rehydration

With SvelteKit a load fills the cache on the server and the browser replays the response.

`RTK Query`

```ts
// The store is rehydrated from the server's state:
export const api = createApi({
	// …
	extractRehydrationInfo(action, { reducerPath }) {
		if (action.type === HYDRATE) return action.payload[reducerPath];
	}
});
```

`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

`RTK Query`

```ts
// Redux DevTools: the api slice and every query/mutation action
configureStore({ reducer, middleware, devTools: true });
```

`Svelte Lean Query`

```ts
createClient({
	transport: http({ base: '/' }),
	modules: [devtools({ panel: true })] // a stub in production builds
});
```

### Tests

`RTK Query`

```ts
const store = setupStore();
server.use(http.get('*/api/members', () => HttpResponse.json(emptyPage)));
render(<Provider store={store}><MemberList /></Provider>);
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

- **No store.** State lives in the client and in one signal per field of each binding; there is no reducer pass or selector per update.
- **Endpoints are values.** Each is its own export, so an application that defines hundreds of endpoints ships the ones it imports.
- **Access on the definition.** A permission check is part of the endpoint: nothing is sent without the right, and cached data is removed when the right is withdrawn.
- **loading and refreshing.** Decided by the fetch's cause: a poll or an invalidation never shows a skeleton.
- **Layers, not patches to undo.** A failed write removes only its own change.

## Order of a migration

1. The client: transport, profile, scope and permission check ([Client setup](https://svelte-lean.vercel.app/query/client.md)).
2. The tag names of `tagTypes` become the `Tags` registry, with the id type of each.
3. Endpoints, feature by feature: each `build.query` and `build.mutation` becomes an exported definition; the screens switch from the generated hooks to `createQuery` and `createMutation`.
4. Remove the API slice from the store, then measure the script size of each section.
