Vanilla Signal Query Documentation

vanilla-signal-query is an asynchronous state management library designed for native JavaScript business request scenarios. It provides reactive request states, pluggable data caching, request deduplication, retry mechanisms, timeout handling, cancellation, prefetching, and cache invalidation capabilities.

Design Goals

  • Independent data layer: Only handles requests, caching, state, and invalidation without binding to DOM rendering.
  • Functional API: Uses createQuery to return a callable data accessor; read data by directly calling query().
  • Business-request friendly: Built-in support for commonly used page capabilities like status, isLoading, isFetching, isStale, failureCount, refetch, retry, and mutate.
  • Cross-instance caching: Same queryKey shares cache records and pending requests.
  • Controlled consistency: Supports staleTime, cache adapter ttl, invalidateQueries, removeQueries, and prefetchQuery.
  • Request safety: Supports AbortController, timeout, request deduplication, retry mechanisms, and race condition protection.

createQuery Form

createQuery returns a callable function. The function itself is used to read current data, with state and control methods attached to the function object:

JavaScript
const user = createQuery({ queryKey: ['user', userId], queryFn: async ({ queryKey, signal }) => { const response = await fetch(`/api/users/${queryKey[1]}`, { signal }); return response.json(); },});user(); // Current datauser.state.status;user.refetch();

This form places “reading data” and “controlling requests” on the same query object, suitable for business requests like lists, details, search, and dashboard cards.

Basic Usage

JavaScript
const products = createQuery({ queryKey: ['products'], queryFn: async ({ signal }) => { const response = await fetch('/api/products', { signal }); return response.json(); },});createEffect(() => { if (products.state.isLoading) { console.log('loading'); return; } if (products.state.isError) { console.error(products.state.error); return; } console.log(products());});

After createQuery is created, it executes automatically by default. The return value is a function; call it to read the current data.

State Fields

JavaScript
query.state.data;query.state.latest;query.state.error;query.state.failureCount;query.state.status; // pending | success | errorquery.state.fetchStatus; // idle | fetchingquery.state.isPending;query.state.isLoading;query.state.isFetching;query.state.isStale;query.state.isSuccess;query.state.isError;query.state.isPaused;query.state.dataUpdatedAt;query.state.errorUpdatedAt;query.state.updatedAt;

Common distinctions:

  • isLoading: Currently no displayable data and requesting.
  • isFetching: Currently requesting, which could be initial loading or background refresh.
  • isStale: Current data is displayable but has expired or is waiting for new results using old data.
  • status: Describes the data result state.
  • fetchStatus: Describes the request process state.

queryKey

queryKey is used for caching, deduplication, and invalidation. Arrays are recommended:

JavaScript
createQuery({ queryKey: ['products', { page: 1, keyword: 'phone' }], queryFn,});

Object keys are stably sorted, so the following two keys are equivalent:

JavaScript
['products', { page: 1, keyword: 'phone' }];['products', { keyword: 'phone', page: 1 }];

queryKey can be a reactive accessor:

JavaScript
const [page, setPage] = createSignal(1);const list = createQuery({ queryKey: () => ['products', page()], keepPreviousData: true, queryFn: ({ queryKey }) => fetchPage(queryKey[1]),});

When page() changes, the query automatically switches keys and requests new data.

queryFn

JavaScript
queryFn({ queryKey, attempt, signal, meta,});
  • queryKey: The current parsed key.
  • attempt: Which attempt number, starting from 1.
  • signal: Used to cancel fetch.
  • meta: Additional information passed via refetch({ meta }) or prefetchQuery({ meta }).

queryFn is the request execution boundary. vanilla-signal-query manages query state and cache; the function itself decides how to request data and what business data to return.

TypeScript Types

createQuery can infer simple data automatically. For stricter projects, pass generics for final data, raw query function data, and tuple query keys:

TypeScript
const user = createQuery<User, UserResponse, ['user', number]>({ queryKey: () => ['user', userId()], queryFn: async ({ queryKey, signal }) => { const response = await fetch(`/api/users/${queryKey[1]}`, { signal }); return response.json(); }, select: (response) => response.data,});

The exported types include Query, QueryOptions, QueryFn, QueryState, MaybeQueryAccessor, cache option types, and cache error context types.

Common Methods

JavaScript
query.refetch(); // Force background refresh, keeps old data by defaultquery.reload(); // Force reload, doesn't keep old data by defaultquery.retry(); // Force another requestquery.mutate(updater); // Local update and write to cachequery.invalidate(); // Mark current query cache as stalequery.remove(); // Delete current cache and reset statequery.abort(); // Abort current request and ignore old resultsquery.destroy(); // Destroy reactive effects and requestsquery.promise(); // Current pending promisequery.key(); // Current hash keyquery.queryKey(); // Current original queryKeyquery.subscribe((state) => {});

Caching Strategy

Caching is enabled by default:

JavaScript
createQuery({ queryKey: ['user', 1], staleTime: 1000 * 30, cache: { enabled: true, adapter: 'memory', options: { ttl: 1000 * 60 * 5, maxSize: 100, }, }, queryFn,});
  • staleTime: Duration data remains fresh. Default is 0, meaning immediately available for background refresh after success.
  • cache: true: Use the default memory adapter.
  • cache: false: Disable caching.
  • cache.enabled: Enables or disables cache when using object config.
  • cache.adapter: One of memory, cookie, localStorage, or indexedDB.
  • cache.options.ttl: Cache record retention time. Default is 5 minutes and applies to every adapter.
  • cache.options.maxSize: Maximum LRU entries for the memory adapter. Default is 100.
  • cache.options.namespace: Storage namespace for persistent adapters. Default is signal.

Example:

JavaScript
const user = createQuery({ queryKey: ['user', id], staleTime: 60_000, cache: { adapter: 'localStorage', options: { namespace: 'app-query', ttl: 10 * 60_000, }, }, queryFn,});

Creating a query with the same key within one minute will use the cache directly; after ten minutes, the cache record expires from the configured adapter.

Adapter defaults:

Adapter Backing store Default options
memory vanilla-lru { ttl: 300000, maxSize: 100 }
cookie vanilla-create-storage cookie driver { ttl: 300000, namespace: 'signal' }
localStorage vanilla-create-storage localStorage driver { ttl: 300000, namespace: 'signal' }
indexedDB vanilla-create-storage indexedDB driver { ttl: 300000, namespace: 'signal' }

Persistent adapters keep an in-memory shadow cache and hydrate data from the selected browser storage. Query execution and prefetchQuery use the async cache path and can wait for hydration. getQueryEntry is synchronous and reads the cache view that is currently available in memory.

Query Client

The default export is queryClient, or you can create an independent client:

JavaScript
const client = createQueryClient({ cache: { adapter: 'memory', options: { maxSize: 300, ttl: 10 * 60_000, }, },});const query = createQuery({ client, queryKey: ['orders'], queryFn,});

Prefetching

JavaScript
await queryClient.prefetchQuery({ queryKey: ['product', 1], staleTime: 60_000, queryFn: () => fetchProduct(1),});

Reading and Writing Cache

JavaScript
queryClient.getQueryData(['product', 1]);queryClient.setQueryData(['product', 1], (previous) => ({ ...previous, liked: true,}));

Invalidation and Deletion

JavaScript
queryClient.invalidateQueries(['products']);queryClient.removeQueries(['products', 1]);queryClient.clear();

Array filter supports prefix matching; ["products"] can match ["products", 1], ["products", 2].

Listening to Client Events

JavaScript
const unsubscribe = queryClient.subscribe((event) => { console.log(event.type, event.key);});

Event types include set, fetch, success, error, cache-error, invalidate, remove, and clear.

JavaScript
const unsubscribe = queryClient.subscribe((event) => { if (event.type === 'cache-error') { console.error(event.error); }});

Persistent cache write failures do not change the successful query result, but they are exposed through cache-error.

Retry

JavaScript
createQuery({ queryKey: ['report'], retry: 2, retryDelay: (attempt) => attempt * 500, queryFn,});

By default, 4xx errors and AbortError are not retried. You can customize:

JavaScript
createQuery({ retry: (attempt, error) => attempt < 3 && error.status >= 500, shouldRetry: (error) => error.name !== 'AbortError', queryFn,});

Timeout and Abort

JavaScript
const query = createQuery({ queryKey: ['slow'], timeout: 8000, queryFn: ({ signal }) => fetch('/api/slow', { signal }).then((r) => r.json()),});query.abort();

Request timeout throws a TimeoutError and attempts to abort the current request.

Business Response Normalization

Default support for { success, data, message, code } style responses:

JavaScript
{ success: true, data: [...] }{ success: false, message: "No access", code: "NO_ACCESS" }

success: false is converted to BusinessError.

If the backend structure differs, you can pass normalize:

JavaScript
createQuery({ queryKey: ['items'], normalize(response) { if (response.errno !== 0) { throw new Error(response.errmsg); } return { data: response.result }; }, queryFn,});

Disable normalization:

JavaScript
createQuery({ normalize: false, queryFn,});

select

select is used to derive final data written to state/cache from response data:

JavaScript
createQuery({ queryKey: ['users'], queryFn: fetchUsers, select: (users) => users.filter((user) => user.active),});

enabled

enabled can be a boolean value or an accessor:

JavaScript
const [id, setId] = createSignal(null);const user = createQuery({ enabled: () => id() !== null, queryKey: () => ['user', id()], queryFn,});

When disabled, automatic requests won’t occur, and state.isPaused is true. Manual refetch() forces a request.

Suspense and throwErrors

JavaScript
createQuery({ suspense: true, throwErrors: true, queryFn,});
  • suspense: true: When reading query(), if the initial request is still pending, it throws the current Promise.
  • throwErrors: true: When reading query(), if there’s an error, it throws the current error.

For regular business pages, directly reading query.state is more recommended.

Use Cases

createQuery is suitable for managing business data that needs synchronization with the server:

  • Lists, details, search, pagination, filtering.
  • Multiple UI areas reading the same interface data.
  • Needs for caching, prefetching, deduplication, invalidation, or optimistic updates.
  • Unified handling of loading, refreshing, error, and retry states.

vanilla-signal-query does not handle DOM rendering. The UI layer only consumes query() and query.state; the rendering approach is determined by the application itself.

Last updated 2026-09-23 10:18:51 UTC+8