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
createQueryto return a callable data accessor; read data by directly callingquery(). - Business-request friendly: Built-in support for commonly used page capabilities like
status,isLoading,isFetching,isStale,failureCount,refetch,retry, andmutate. - Cross-instance caching: Same
queryKeyshares cache records and pending requests. - Controlled consistency: Supports
staleTime, cache adapterttl,invalidateQueries,removeQueries, andprefetchQuery. - 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:
JavaScriptconst 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
JavaScriptconst 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
JavaScriptquery.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:
JavaScriptcreateQuery({ 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:
JavaScriptconst [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
JavaScriptqueryFn({ 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 viarefetch({ meta })orprefetchQuery({ 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:
TypeScriptconst 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
JavaScriptquery.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:
JavaScriptcreateQuery({ 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 is0, 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 ofmemory,cookie,localStorage, orindexedDB.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 issignal.
Example:
JavaScriptconst 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:
JavaScriptconst client = createQueryClient({ cache: { adapter: 'memory', options: { maxSize: 300, ttl: 10 * 60_000, }, },});const query = createQuery({ client, queryKey: ['orders'], queryFn,});
Prefetching
JavaScriptawait queryClient.prefetchQuery({ queryKey: ['product', 1], staleTime: 60_000, queryFn: () => fetchProduct(1),});
Reading and Writing Cache
JavaScriptqueryClient.getQueryData(['product', 1]);queryClient.setQueryData(['product', 1], (previous) => ({ ...previous, liked: true,}));
Invalidation and Deletion
JavaScriptqueryClient.invalidateQueries(['products']);queryClient.removeQueries(['products', 1]);queryClient.clear();
Array filter supports prefix matching; ["products"] can match ["products", 1], ["products", 2].
Listening to Client Events
JavaScriptconst unsubscribe = queryClient.subscribe((event) => { console.log(event.type, event.key);});
Event types include set, fetch, success, error, cache-error, invalidate, remove, and clear.
JavaScriptconst 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
JavaScriptcreateQuery({ queryKey: ['report'], retry: 2, retryDelay: (attempt) => attempt * 500, queryFn,});
By default, 4xx errors and AbortError are not retried. You can customize:
JavaScriptcreateQuery({ retry: (attempt, error) => attempt < 3 && error.status >= 500, shouldRetry: (error) => error.name !== 'AbortError', queryFn,});
Timeout and Abort
JavaScriptconst 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:
JavaScriptcreateQuery({ queryKey: ['items'], normalize(response) { if (response.errno !== 0) { throw new Error(response.errmsg); } return { data: response.result }; }, queryFn,});
Disable normalization:
JavaScriptcreateQuery({ normalize: false, queryFn,});
select
select is used to derive final data written to state/cache from response data:
JavaScriptcreateQuery({ queryKey: ['users'], queryFn: fetchUsers, select: (users) => users.filter((user) => user.active),});
enabled
enabled can be a boolean value or an accessor:
JavaScriptconst [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
JavaScriptcreateQuery({ suspense: true, throwErrors: true, queryFn,});
suspense: true: When readingquery(), if the initial request is still pending, it throws the current Promise.throwErrors: true: When readingquery(), 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.