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.

Pick an author to load their posts.

full source β†—
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.

Loading… attempt 0 β€” hang in there

full source β†—
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.

full source β†—
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.

Loading…

full source β†—
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 reload function is called.

Identical loads are always deduplicated: one key, at most one load in flight, no matter how many components ask.

Loading…

Cache entry: 🫧 evicted

full source β†—
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 enabled getter β€” 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 $derived wrapper: plain destructuring captures a one-time snapshot (as with any reactive $state object), while const { 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 call invalidateQueries (or the experimental updateQueryData) once the server is done.
  • No window-focus or interval reloading An $effect with addEventListener or setInterval calling reload does this in three lines, exactly the way you want it.
  • No query-chaining API When one query needs the result of another, point its enabled getter 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, $derived already does it: $derived(query.data?.name) only reacts when the name changes.
  • No devtools queryInfos exposes 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 their initialData) 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 why query.error is 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 failureCount and failureReason, here a retrying query is simply still loading β€” only the final result is stored.
  • Garbage collection only evicts stale data TanStack's gcTime evicts 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 long staleTime cannot 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 queryKey by 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.error is your TError β€” not the unknown of 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.data as TData instead of TData | 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

OptionDefaultWhat it does
staleTime0How 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.
initialDataundefinedThe 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.
gcTimeno evictionEnables 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).
retry0How 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:

FieldTypeBehavior
loadingbooleanTrue while a load (including its retries) is running.
dataTData | undefinedThe 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.
errorTError | undefinedThe error of the last failed load. Cleared the moment a new load starts.
loadedTimeStampnumber | undefinedWhen the current data was stored (epoch milliseconds).
staleTimeStampnumber | undefinedWhen the current data goes (or went) stale.
enabledbooleanThe current value of the enabled invoke option.
reload() => voidTriggers 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:

FieldTypeBehavior
dataTData[] | undefinedAn array of pages, one entry per load.
hasMoreboolean | undefinedWhether the last load returned a cursor. undefined while loading.
loadMore() => voidLoads 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() => voidDiscards all pages and reloads from the start (one page).

Two behavioral differences worth knowing:

  • staleTime defaults to Infinity β€” 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 hasMore are 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[][];
};
FieldTypeBehavior
isLoadingbooleanTrue while any query is loading β€” perfect for a global indicator like the one in this page's navigation.
loadingQueriesstring[][]The keys of all currently loading queries.
activeQueriesstring[][]The keys of all queries used in currently mounted components.
cachedQueriesstring[][]The keys of all queries that currently have cached data.

Built with πŸ¦„ by Kidesia.