sveltelean Query
Version v0.2.0

Names

RTK QuerySvelte Lean Query
createApi + fetchBaseQuerycreateClient + http()
build.query / build.mutationdefineQuery / defineMutation
tagTypesthe Tags registry
providesTags / invalidatesTagstags, entity / invalidates
useXQuery(arg)createQuery(endpoint, () => params)
skipToken, skipparams return undefined, when
useLazyXQuery{ when: false } + load()
pollingIntervalpoll
keepUnusedDataForkeep
refetchOnMountOrArgChangefresh
isLoading, isFetching and the other flagssee States, field by field
updateQueryDataep.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 QuerySvelte Lean QueryMeaning
isUninitializedinfo.status === 'idle'Params return undefined, when is false, or the access rule refuses.
isLoadinginfo.status === 'pending' && !info.placeholderNothing to show yet: the first load of the binding.
isFetchingbusyAny request in flight, the first one included.
isSuccessinfo.status === 'success'The current key has data.
isError, errorerrorA QueryError with kind, status, code and fields.
datadata with keepPrevious: trueRTK keeps the last result across arguments; keepPrevious does the same.
currentDatainfo.placeholder ? undefined : dataOnly the data of the current params.
fulfilledTimeStampinfo.updatedAt0 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
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

API slice and store

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

RTK Query
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
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
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
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
getMember: build.query<Member, number>({
	query: (id) => `api/members/${id}`,
	providesTags: (_member, _error, id) => [{ type: 'Member', id }]
})
Svelte Lean Query
const getMember = defineQuery({
	get: 'api/members/{id}',
	result: type<Member>(),
	entity: 'Member' // provides tag('Member', id)
});

invalidatesTags

RTK Query
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
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
const { data, isLoading, isFetching, error } = useListMembersQuery(filters);
Svelte Lean Query
const members = createQuery(listMembers, () => filters);

Loading states

RTK Query
if (isLoading) return <p>Loading…</p>;
if (error) return <p role="alert">{String(error)}</p>;
// isFetching && !isLoading: a background refetch
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}

skipToken

RTK Query
const member = useGetMemberQuery(id ?? skipToken);
Svelte Lean Query
const member = createQuery(getMember, () => (id ? { id } : undefined));

The skip option

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

RTK Query
const details = useGetMemberQuery(1, { skip: !open });
Svelte Lean Query
const details = createQuery(getMember, () => ({ id: 1 }), { when: () => open });

Previous data while the arg changes

RTK Query
// 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
const list = createQuery(listMembers, () => filters, { keepPrevious: true });

pollingInterval

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

RTK Query
const polled = useListMembersQuery(filters, {
	pollingInterval: 30_000,
	skipPollingIfUnfocused: true
});
Svelte Lean Query
const polled = createQuery(listMembers, () => filters, { poll: () => '30s' });

Lazy query

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

RTK Query
const [trigger, search] = useLazyListMembersQuery();
// on submit: trigger({ search: term });
Svelte Lean Query
const search = createQuery(listMembers, { when: false });
// later: await search.load({ search: term });

Cache

invalidateTags

RTK Query
dispatch(api.util.invalidateTags(['Members']));
dispatch(api.util.invalidateTags([{ type: 'Member', id }]));
Svelte Lean Query
invalidate('Members'); // every entry tagged Members
invalidate(tag('Member', id)); // one member, everywhere it is cached

select and updateQueryData

RTK Query
const cached = membersApi.endpoints.getMember.select(id)(getState()).data;
dispatch(
	membersApi.util.updateQueryData('getMember', id, (draft) => {
		draft.name = 'Amara';
	})
);
Svelte Lean Query
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
// 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
// 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
const prefetchMember = usePrefetch('getMember');
// onMouseEnter={() => prefetchMember(id)}
Svelte Lean Query
getMember.prefetch({ id }); // low priority; errors stay on the entry

initiate

RTK Query
const member = await dispatch(membersApi.endpoints.getMember.initiate(id)).unwrap();
Svelte Lean Query
const member = await getMember.fetch({ id }); // fresh cache, else one request

Writing

The mutation hook

RTK Query
const [updateMember, { isLoading, error }] = useUpdateMemberMutation();
// await updateMember({ id: 1, name: 'Amara' }).unwrap();
Svelte Lean Query
const save = createMutation(updateMember);
// save.run({ id: 1, name: 'Amara' }); save.busy; save.error

unwrap and react

RTK Query
const [updateMember] = useUpdateMemberMutation();
const member = await updateMember(input).unwrap();
toast(`Saved ${member.name}`);
Svelte Lean Query
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
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
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
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
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
// 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
// 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
// Redux DevTools: the api slice and every query/mutation action
configureStore({ reducer, middleware, devTools: true });
Svelte Lean Query
createClient({
	transport: http({ base: '/' }),
	modules: [devtools({ panel: true })] // a stub in production builds
});

Tests

RTK Query
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
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).
  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.