Query Migration
From RTK Query
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.
Markdown version of this page, for assistants (Markdown for AI)
On this page
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), 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 RTK 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
API slice and store
No store and no middleware: one client with a transport; tag names live in a typed registry.
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Member', 'Members'],
endpoints: () => ({})
});
// store.ts: reducer: { [api.reducerPath]: api.reducer }, middleware: (gDM) => gDM().concat(api.middleware)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.
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;const listMembers = defineQuery({
get: 'api/members',
params: type<MemberFilters>(),
result: page<Member>(),
tags: ['Members'],
fresh: '15s', // staleTime
keep: '5m' // gcTime
});providesTags per id
getMember: build.query<Member, number>({
query: (id) => `api/members/${id}`,
providesTags: (_member, _error, id) => [{ type: 'Member', id }]
})const getMember = defineQuery({
get: 'api/members/{id}',
result: type<Member>(),
entity: 'Member' // provides tag('Member', id)
});invalidatesTags
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'] : [])
})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.
const { data, isLoading, isFetching, error } = useListMembersQuery(filters);const members = createQuery(listMembers, () => filters);Loading states
if (isLoading) return <p>Loading…</p>;
if (error) return <p role="alert">{String(error)}</p>;
// isFetching && !isLoading: a background refetch{#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
const member = useGetMemberQuery(id ?? skipToken);const member = createQuery(getMember, () => (id ? { id } : undefined));The skip option
when pauses and keeps the data; it never deletes it.
const details = useGetMemberQuery(1, { skip: !open });const details = createQuery(getMember, () => ({ id: 1 }), { when: () => open });Previous data while the arg changes
// The hook keeps the last result of another arg in `data`;
// `currentData` holds only this arg's result.
const { data, currentData, isFetching } = useListMembersQuery(filters);const list = createQuery(listMembers, () => filters, { keepPrevious: true });pollingInterval
The shortest active interval wins; the deadlines do not drift with latency.
const polled = useListMembersQuery(filters, {
pollingInterval: 30_000,
skipPollingIfUnfocused: true
});const polled = createQuery(listMembers, () => filters, { poll: () => '30s' });Lazy query
After load() the binding is an ordinary query: no second subscription model.
const [trigger, search] = useLazyListMembersQuery();
// on submit: trigger({ search: term });const search = createQuery(listMembers, { when: false });
// later: await search.load({ search: term });Cache
invalidateTags
dispatch(api.util.invalidateTags(['Members']));
dispatch(api.util.invalidateTags([{ type: 'Member', id }]));invalidate('Members'); // every entry tagged Members
invalidate(tag('Member', id)); // one member, everywhere it is cachedselect and updateQueryData
const cached = membersApi.endpoints.getMember.select(id)(getState()).data;
dispatch(
membersApi.util.updateQueryData('getMember', id, (draft) => {
draft.name = 'Amara';
})
);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.
// 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;
})
);
}// 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
const prefetchMember = usePrefetch('getMember');
// onMouseEnter={() => prefetchMember(id)}getMember.prefetch({ id }); // low priority; errors stay on the entryinitiate
const member = await dispatch(membersApi.endpoints.getMember.initiate(id)).unwrap();const member = await getMember.fetch({ id }); // fresh cache, else one requestWriting
The mutation hook
const [updateMember, { isLoading, error }] = useUpdateMemberMutation();
// await updateMember({ id: 1, name: 'Amara' }).unwrap();const save = createMutation(updateMember);
// save.run({ id: 1, name: 'Amara' }); save.busy; save.errorunwrap and react
const [updateMember] = useUpdateMemberMutation();
const member = await updateMember(input).unwrap();
toast(`Saved ${member.name}`);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.
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();
}
}
})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
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);const feed = createInfinite(listMembers, () => filters, { perPage: 50 });
// feed.rows, feed.hasMore, feed.loadMore(), feed.loadingMoreServer rendering
Rehydration
With SvelteKit a load fills the cache on the server and the browser replays the response.
// The store is rehydrated from the server's state:
export const api = createApi({
// …
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === HYDRATE) return action.payload[reducerPath];
}
});// 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
// Redux DevTools: the api slice and every query/mutation action
configureStore({ reducer, middleware, devTools: true });createClient({
transport: http({ base: '/' }),
modules: [devtools({ panel: true })] // a stub in production builds
});Tests
const store = setupStore();
server.use(http.get('*/api/members', () => HttpResponse.json(emptyPage)));
render(<Provider store={store}><MemberList /></Provider>);
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
- 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
- The client: transport, profile, scope and permission check (Client setup).
- The tag names of
tagTypesbecome theTagsregistry, with the id type of each. - Endpoints, feature by feature: each
build.queryandbuild.mutationbecomes an exported definition; the screens switch from the generated hooks tocreateQueryandcreateMutation. - Remove the API slice from the store, then measure the script size of each section.