Declarative queries
for SvelteΒ 5
Caching, deduping, invalidation, retries and cancellation in less than 2kB β built entirely on Svelte 5's reactivity. No provider, no setup, no magic.
npm install svelte-tiny-query Everything on this page is live. The idle indicator in
the navigation is queryInfos.isLoading β it lights up
whenever any query on this page loads. All demos share one fake server with
~700ms of latency.
Your first query
A query is a key plus a loading function that returns data or an error. Invoke
it at the top level of a component and you get reactive loading, data and error state,
plus a reload function. If several components use the same
query, the loading function still runs only once β and all of them share
the result. In a real app you would define queries once, in a shared
module, and import them wherever they are used (see Keys).
{
loading: true,
data: undefined,
error: undefined,
loadedTimeStamp: undefined,
staleTimeStamp: undefined,
enabled: true,
reload: Ζ
} full source βimport { createQuery, succeed, fail } from 'svelte-tiny-query';
const useMemeIdeas = createQuery(['meme-ideas'], async () => {
try {
return succeed(await fetchMemeIdeas());
} catch {
return fail('Could not load meme ideas π’');
}
});
const query = useMemeIdeas(); Try reloading β the old list stays visible while the new one loads (stale-while-revalidate), and errors are cleared the moment a new load starts.
Reactive parameters
Queries can take one parameter, passed as a thunk so it
stays reactive β when it changes, the query switches to the matching
part of the cache and loads it if needed. Each parameter value gets its
own cache entry. This demo also sets staleTime: 10_000:
data younger than 10 seconds is served straight from the cache, without
a reload.
Loading idea #1β¦
full source βlet selectedId = $state(1);
const useMemeIdea = createQuery(
['meme-idea'],
async (id: number) => {
try {
return succeed(await fetchMemeIdea(id));
} catch {
return fail('Could not load this one π’');
}
},
{ staleTime: 10_000 }
);
// Reactive params are passed as thunks
const query = useMemeIdea(() => selectedId); Flip between ideas: the first visit loads, quick revisits are instant, and after 10 seconds a revisit reloads in the background.
Mutations are just functions
There is no mutation API β write a function that triggers a mutation on
the server, then call invalidateQueries with a key. Every active query under that
key reloads (keys are hierarchical, so invalidating ['memes'] also hits ['memes', '7']). Since the
cache is global, this list shares its key β and therefore its data β
with the first demo.
And since mutations are plain async functions, their promise is your pending state: await it to drive spinners and disable buttons β exactly what the βSavingβ¦β button in this demo does.
import { invalidateQueries } from 'svelte-tiny-query';
let newIdea = $state('');
// Mutations are just functions
async function add() {
await addMemeIdea(newIdea);
invalidateQueries(['meme-ideas']);
newIdea = '';
} Add an idea and scroll up β the first demo updated too. Same key, same cache.
Dependent queries
The enabled option (a reactive getter) holds a query back
until its prerequisites are ready. While disabled, the query does not
load, loading is false, and reload does nothing. The moment it flips to true, loading starts.
let author = $state<string | undefined>(undefined);
const usePosts = createQuery(['posts'], async (author: string) => {
try {
return succeed(await fetchPostsBy(author));
} catch {
return fail('Could not load posts π’');
}
});
// Only loads once an author is picked
const query = usePosts(() => author ?? '', {
enabled: () => author !== undefined
}); Errors and retries
Errors are returned, not thrown: the loading function
produces either data or an error (the succeed and fail helpers construct the two shapes), which is why query.error is fully typed instead of the unknown of a catch block. A failed reload keeps the
previous data visible β stale-while-revalidate β and the error
is cleared the moment the next load starts.
With the retry option, failed loads are retried with exponential
backoff (1s, 2s, 4s, β¦ capped at 30s). Retrying is invisible from the outside:
the query simply stays in its loading state, intermediate errors are never
exposed, and only the final result is stored.
const useWobbly = createQuery(
['wobbly'],
async () => {
const response = await wobblyServer();
return response.ok
? succeed(response.data)
: fail('The server flaked out π« ');
},
{ retry: 2 } // 3 attempts in total, with 1s and 2s of backoff
);
const query = useWobbly(); The hopeless variant fails all three attempts and lands in the error state after the backoff runs out.
Sequential queries
createSequentialQuery is the cursor-based sibling of createQuery, for pagination and load-more lists. The
loading function receives a cursor and returns one page plus the next
cursor β returning undefined means there is no more data.
The pages accumulate in data, an array of pages.
import { createSequentialQuery } from 'svelte-tiny-query';
const useComments = createSequentialQuery(
['comments'],
async (_, cursor) => {
try {
const page = await fetchComments(cursor ?? 0);
return {
success: true,
data: page.items,
cursor: page.next // undefined = no more pages
};
} catch {
return { success: false, error: 'Could not load comments π’' };
}
}
);
const query = useComments(); Cancellation
Every loading function receives an AbortSignal.
Invalidating a query that is currently loading cancels the in-flight
load β its result is discarded entirely β and starts a fresh one, so a
response from before the invalidation can never sneak into the cache.
Pass the signal to fetch to abort the request itself.
const useSlow = createQuery(['slow'], async (_, signal) => {
signal.addEventListener('abort', () => log('cancelled!'));
const data = await slowFetch(signal); // takes 3 seconds
return succeed(data);
});
const query = useSlow();
// cancels the in-flight load and starts a fresh one
invalidateQueries(['slow']); Start the slow load, then invalidate mid-flight and watch the log: the first load is cancelled, and only the fresh result is stored.
The cache and garbage collection
Cached data lives through three stages: fresh (served
without reloading), stale (shown instantly, reloaded on
use) and β if you opt in with gcTime β gone. A query is evicted gcTime milliseconds after it is both unused and stale; fresh
data is never collected. Without gcTime, the cache is kept
for the lifetime of the app, which is fine for most queries.
To be precise, a query loads when:
- it is first used (unless fresh cached data exists),
- its key changes, via a reactive parameter,
- it is used again (e.g. remounted) and its data is stale,
- it is invalidated via
invalidateQueries, - its
reloadfunction is called.
Identical loads are always deduplicated: one key, at most one load in flight, no matter how many components ask.
const useGcDemo = createQuery(
['gc-demo'],
loadFn,
{ gcTime: 5000 } // evicted 5s after unused + stale
);
// elsewhere: watch the cache itself
const isCached = $derived(
queryInfos.cachedQueries.some(([first]) => first === 'gc-demo')
); Unmount the component and watch the cache entry vanish after 5 seconds β then remount within the window and see the eviction get cancelled.
The one rule
Query functions must be invoked at the top level of a component β not inside $derived or $effect, not in an event handler,
and not in an {#each} expression. Invoking a query
wires it into the component's lifecycle (that is how loading is triggered,
and how the query counts as active), so it has to happen while the component
is being set up. The library logs a console warning when it detects a misuse.
// β
invoke at the top level of the component
const query = useMeme(() => id);
// β
conditional? keep the invocation, gate the loading
const query = useMeme(() => id, { enabled: () => visible });
// β not inside $derived or $effect (console warning)
const query = $derived(useMeme(() => id));
// β no plain destructuring β getters lose their reactivity
const { data, loading } = useMeme(() => id);
// β
...unless the query state is wrapped in $derived
const query = useMeme(() => id);
const { data, loading } = $derived(query); Everything you would reach into a reactive context for has a declarative counterpart:
- Reactive parameters are thunks:
useMeme(() => id). - Conditional loading is the
enabledgetter β the invocation stays, the loading is gated. - A query per list item means invoking the query inside a child component rendered by the list.
- Destructuring wants a
$derivedwrapper: plain destructuring captures a one-time snapshot (as with any reactive$stateobject), whileconst { data } = $derived(query)keeps every binding reactive.
This rule is the price of the declarative model, and it is not unique to this library: React Query users know it as the rules of hooks, and TanStack's Svelte adapter must also be created during component initialization.
Why this library?
Svelte Tiny Query was first conceived when Svelte 5 was still in beta, embedded in a very young app (that we still going strong today). It is inspired by TanStack Query β which at the time had no Svelte 5 adapter. After copying it from codebase to codebase for a while, we decided to extract it into this library.
TanStack Query has long since caught up: its Svelte adapter supports Svelte 5 today and offers capabilities beyond this library β SSR hydration, offline support, devtools and more. If you need those, use it, it is excellent. We keep using Svelte Tiny Query because its small surface fits our apps (and our heads).
A note on AI
Version 1.0 of Svelte Tiny Query was designed and written entirely by hand, before AI assistance was part of our workflow. Since then, AI has assisted the development β hunting edge cases, writing regression tests and helping with fixes and features β while the design and direction of the library remain human decisions. This docs page was also built with the help of AI.
What is left out (on purpose)
This library is tiny β honestly, more by accident than by discipline. Svelte 5's reactivity solves caching almost by itself, so about 2kB, no dependencies and a handful of concepts (a key, a loading function, one global cache) is simply what was left to write. The deliberate part is staying this way: when a feature would require a new concept, we would rather leave it out and show you the few lines of Svelte that do the same thing.
- No query provider The cache is global β there is nothing to set up.
- No mutation API Mutations are plain async functions. Await the promise to drive spinners
and disabled states with local
$state, then callinvalidateQueries(or the experimentalupdateQueryData) once the server is done. - No window-focus or interval reloading An
$effectwithaddEventListenerorsetIntervalcallingreloaddoes this in three lines, exactly the way you want it. - No query-chaining API When one query needs the result of another, point its
enabledgetter at the first query's state:usePosts(() => user.data.id, { enabled: () => !!user.data }). No extra concept needed (see βDependent queriesβ above). - No select or subscription slicing The query state is deeply reactive, so only the parts of the UI that actually
use a changed value update. Where you would reach for select,
$derivedalready does it:$derived(query.data?.name)only reacts when the name changes. - No devtools
queryInfosexposes the primitive (loading, active and cached queries); build the panel your app actually needs. - No SSR fetching or cache hydration This one is honestly out of scope rather than a few lines of DIY. Loading
happens in
$effect, which only runs in the browser β during SSR, queries render in their loading state (or with theirinitialData) and fetch after hydration. That is also why the global module-level cache is harmless on the server.
Differences to TanStack Query
Missing features are listed above β this is about the things both libraries do, done differently. If you come from TanStack Query, these are the behaviors to re-learn:
- Errors are returned, not thrown The loading function returns
fail(error)instead of throwing, which is whyquery.erroris fully typed. A thrown exception is treated as a bug (a defect): it is reported to your error monitoring, and the typed error state stays untouched. - Retries are invisible Where TanStack exposes
failureCountandfailureReason, here a retrying query is simply still loading β only the final result is stored. - Garbage collection only evicts stale data TanStack's
gcTimeevicts inactive queries regardless of freshness. Here, a query is evicted once it has been both unused and stale β fresh data is never collected, so a longstaleTimecannot be undermined by eviction. - One global cache instead of a QueryClient There is no client object and no provider β the cache is a module-level singleton, which is exactly what a client-rendered app needs (and part of why SSR is out of scope).
- One parameter instead of a query-key array Queries take a single serializable parameter that is appended to the key
automatically β instead of encoding all inputs into the
queryKeyby hand.
And much is deliberately the same: stale-while-revalidate, deduplication of identical loads, hierarchical keys and invalidation, and the invoke-at-the-top-level rule (their rules of hooks). If you know TanStack Query, you already know how this library thinks.
API Reference
The whole library: two query constructors, two cache tools, two helpers and one readonly object. This is everything.
TypeScript
The library is written in TypeScript and designed for inference: annotate the
loading function's parameter, and everything else follows. The data type flows
from what you succeed with, the error type from what you fail with, and both arrive fully typed on the query state β in everyday
use you never write a generic.
const useMeme = createQuery(['memes'], async (id: number) => {
try {
return succeed(await fetchMeme(id)); // infers TData: Meme
} catch {
return fail('Could not load meme'); // infers TError: string
}
});
const query = useMeme(() => 7); // param must be a number
query.data; // Meme | undefined
query.error; // string | undefined Where the types are specific on purpose:
- Errors are typed values. Because loading functions return
errors instead of throwing them,
query.erroris yourTErrorβ not theunknownof a catch block. - TError comes first in the generics list. The error type is the one you most often want to pin down explicitly (e.g. to
a shared
ApiError), so it can be given without spelling out the rest:createQuery<ApiError>(key, loadFn)β param and data are still inferred. - initialData narrows data. When it is provided, an overload types
query.dataasTDatainstead ofTData | undefined. - Params must be serializable. The parameter type is
constrained to
QueryParamβ passing something that cannot become part of a cache key is a type error, not a silent bug.
These are the exported types that the reference below is written in. Whenever one of them appears in a signature, you can hover it to see its definition again (or click it to come back here):
type LoadResult<TData, TError> =
| { success: true; data: TData }
| { success: false; error: TError };
type QueryState<TData, TError> = {
loading: boolean;
data: TData | undefined; // TData, if initialData was provided
error: TError | undefined;
loadedTimeStamp: number | undefined;
staleTimeStamp: number | undefined;
enabled: boolean;
reload: () => void;
};
type QueryInvokeOptions = {
enabled?: () => boolean;
};
type QueryParam = // any serializable value
| string | number | boolean | bigint | symbol
| null | undefined | Date | RegExp
| QueryParam[] | { [key: string]: QueryParam };
type SequentialLoadResult<TData, TCursor, TError> =
| { success: true; data: TData; cursor: TCursor | undefined }
| { success: false; error: TError };
type SequentialQueryState<TData, TError> = {
loading: boolean;
data: TData[] | undefined; // an array of pages
error: TError | undefined;
hasMore: boolean | undefined;
loadedTimeStamp: number | undefined;
staleTimeStamp: number | undefined;
enabled: boolean;
loadMore: () => void;
reload: () => void;
}; createQuery
function createQuery<TError, TParam extends QueryParam, TData>(
key: string[] | ((param: TParam) => string[]),
loadFn: (param: TParam, signal: AbortSignal) => Promise<LoadResult<TData, TError>>,
options?: { initialData?: TData; staleTime?: number; gcTime?: number; retry?: number }
): (param?: TParam | (() => TParam), options?: QueryInvokeOptions) => QueryState<TData, TError>; Creates a query function. Invoke it at the top level of a component to get reactive access to the query state. In practice it looks like this:
const useMemeIdea = createQuery(
// a key, which identifies the data in the cache
['meme-idea'],
// a loading function, which produces the data (or an error)
async (id, signal) => {
try {
return succeed(await fetchMemeIdea(id, signal));
} catch {
return fail('Could not load π’');
}
},
// options β all optional, explained below
{ staleTime: 10_000, gcTime: 60_000, retry: 2 }
);
// invoking it (at the top level!) returns the reactive query state
const query = useMemeIdea(() => id, { enabled: () => loggedIn }); Options
| Option | Default | What it does |
|---|---|---|
| staleTime | 0 | How long (in milliseconds) loaded data counts as fresh. Fresh data is served from the cache without reloading; stale data
is shown immediately but reloaded in the background when the query is
used again. Infinity means the data never goes stale. |
| initialData | undefined | The value of data before the first load. Providing it
narrows the type of data from TData | undefined to TData. Useful for
persisted or precomputed data. |
| gcTime | no eviction | Enables garbage collection: the cached state is evicted gcTime milliseconds after the query is both unused (in no mounted component) and stale. Fresh data is never collected, so with staleTime: Infinity the cache lives forever. Set it on queries
whose parameter space is unbounded (search input, per-item views). |
| retry | 0 | How many times a failed load is retried before the error is stored. Backoff is exponential: 1s, 2s, 4s, β¦ capped at 30s. Retrying is invisible β the query stays in its loading state and only the final result lands. |
The query function
The returned function takes the parameter (as a plain value or as a thunk β
use a thunk whenever the parameter is reactive) and optional invoke options. enabled is a reactive getter: while it returns false, the query does not load, loading is false, and reload does nothing. It returns the reactive
query state:
| Field | Type | Behavior |
|---|---|---|
| loading | boolean | True while a load (including its retries) is running. |
| data | TData | undefined | The last successful data. Stays visible during reloads and even when a
later load fails (stale-while-revalidate). Loaded null values are preserved, they do not fall back to initialData. |
| error | TError | undefined | The error of the last failed load. Cleared the moment a new load starts. |
| loadedTimeStamp | number | undefined | When the current data was stored (epoch milliseconds). |
| staleTimeStamp | number | undefined | When the current data goes (or went) stale. |
| enabled | boolean | The current value of the enabled invoke option. |
| reload | () => void | Triggers a load. A no-op while the query is disabled or a load for the same key is already running (loads are never concurrent per key). |
The loading function
The loading function receives the parameter and an AbortSignal, and returns a LoadResult (see the types
above) β errors are returned, not thrown, so wrap throwing
code in try/catch. The two helpers construct the two
shapes:
succeed(data); // returns { success: true, data }
fail(error); // returns { success: false, error } The signal aborts when the library cancels the load β currently when the query
is invalidated while loading. Pass it to fetch to abort the request
over the network; even if you ignore it, the result of a cancelled load is always
discarded (no data, error or timestamps are stored).
If a loading function throws anyway, that is treated as a defect β a bug, not an expected error: query.error stays untouched (and typed), the previous data is
kept, the loading state recovers, and the exception is reported to the global
error handlers via reportError, where monitoring tools like
Sentry pick it up.
Keys and serialization
The key uniquely identifies the data of a query in the global cache β two
queries with the same key share their state (and overwrite each other, so keep
keys unique per resource). Parameters are serialized deterministically: object
keys are sorted, and string, number, boolean, bigint, Date, RegExp, arrays and plain
objects are supported.
// Array key: the serialized param is appended automatically
const useMeme = createQuery(['memes'], loadFn);
useMeme(() => 7); // cached as ['memes', '7']
useMeme(() => ({ id: 7 })); // cached as ['memes', '{"id":7}']
// Key function: full control, e.g. for nested keys
const useComments = createQuery(
(id: number) => ['memes', String(id), 'comments'],
loadFn
); Nested keys enable hierarchical invalidation: invalidating ['memes'] also hits ['memes', '7', 'comments'].
Because keys must be unique, each resource should have exactly one query definition β never create two queries with the same key but different loading functions, or they will fight over the same cache entry. The easy way to guarantee this: define your queries in a shared module and import them wherever they are used. Creating a query registers no reactivity (only invoking it does), so module level is exactly where definitions belong.
// lib/queries.ts β one definition per resource
export const useMemeIdea = createQuery(['meme-idea'], loadMemeIdea);
export const useComments = createSequentialQuery(['comments'], loadComments);
// in any component: import and invoke
import { useMemeIdea } from '$lib/queries';
const query = useMemeIdea(() => id); createSequentialQuery
function createSequentialQuery<TError, TParam extends QueryParam, TData, TCursor>(
key: string[] | ((param: TParam) => string[]),
loadFn: (param: TParam, cursor: TCursor | undefined, signal: AbortSignal) =>
Promise<SequentialLoadResult<TData, TCursor, TError>>,
options?: { initialData?: TData[]; staleTime?: number; gcTime?: number; retry?: number }
): (param?: TParam | (() => TParam), options?: QueryInvokeOptions) => SequentialQueryState<TData, TError>; The cursor-based sibling of createQuery for pagination. In practice:
const useComments = createSequentialQuery(
['comments'],
// the loading function receives a cursor (undefined for the first page)
async (_, cursor, signal) => {
const page = await fetchComments(cursor ?? 0);
return {
success: true,
data: page.items,
cursor: page.next // undefined = no more pages
};
},
// same options as createQuery β but staleTime defaults to Infinity
{ staleTime: Infinity }
);
const query = useComments(); Its state differs from a normal query in a few ways:
| Field | Type | Behavior |
|---|---|---|
| data | TData[] | undefined | An array of pages, one entry per load. |
| hasMore | boolean | undefined | Whether the last load returned a cursor. undefined while loading. |
| loadMore | () => void | Loads the next page with the current cursor. A no-op when there is no more data, while disabled, or while a load is running. |
| reload | () => void | Discards all pages and reloads from the start (one page). |
Two behavioral differences worth knowing:
staleTimedefaults toInfinityβ using a sequential query again does not automatically reload it, because that reload refetches all current pages in order (stopping early if the data shrank). Failed multi-page reloads keep the previous pages and cursor consistent.- Cursor and
hasMoreare only updated after a load fully succeeds.
invalidateQueries
function invalidateQueries(
key: string[],
options?: { force?: boolean; exact?: boolean }
): void; invalidateQueries(['memes']);
// ...matches ['memes'] and all children, like ['memes', '7']
invalidateQueries(['memes', '7'], { exact: true });
// ...matches only exactly ['memes', '7']
invalidateQueries(['memes'], { force: true });
// ...additionally forgets all cached state right away Invalidated queries are marked stale, and the active ones
(used in a mounted component) reload immediately β deduplicated, so a query
used by five components loads once. If a matching query is loading at that
moment, the in-flight load is cancelled and a fresh one starts, so responses
that predate the invalidation are never stored. With force: true, all cached state (data, errors, timestamps) is
forgotten immediately instead of being kept while reloading.
updateQueryData experimental
function updateQueryData(
key: string[],
updater: (currentData: unknown) => unknown
): void; updateQueryData(['memes', '7'], (current) => ({
...(current as Meme),
title: 'A better title'
})); Directly rewrites the cached data of all active queries whose key starts with the given key β the building block for optimistic updates. The API is experimental and may change.
queryInfos
const queryInfos: {
isLoading: boolean;
loadingQueries: string[][];
activeQueries: string[][];
cachedQueries: string[][];
}; | Field | Type | Behavior |
|---|---|---|
| isLoading | boolean | True while any query is loading β perfect for a global indicator like the one in this page's navigation. |
| loadingQueries | string[][] | The keys of all currently loading queries. |
| activeQueries | string[][] | The keys of all queries used in currently mounted components. |
| cachedQueries | string[][] | The keys of all queries that currently have cached data. |
Built with π¦ by Kidesia.