Vanilla Signal Query Design Documentation
Design Principles
vanilla-signal-query focuses on server state management. It handles query state, caching, invalidation, and request lifecycle, without binding to DOM rendering or prescribing UI organization patterns.
Core principles:
- Data layer independence: queries do not directly manipulate the DOM.
- Observable state: each query exposes a reactive
state. - Shareable cache: identical
queryKeycan reuse cached data and pending requests. - Controlled refresh: control data freshness through
staleTime, cache adapterttl, andinvalidateQueries. - Request safety: handle cancellation, timeouts, retries, and race conditions via AbortController, timeout, retry mechanisms, and request IDs.
Architecture
The current project consists of three runtime layers:
createQuery(options): A single business request instance responsible for parsingqueryKey, maintaining state, triggering requests, and exposing control methods.createQueryClient(options): Request coordinator responsible for pending request deduplication, prefetching, invalidation, deletion, and event notifications.- Cache adapters: storage backends responsible for retaining query records in memory, cookie, localStorage, or indexedDB.
The actual HTTP or async data request lives in queryFn. vanilla-signal-query calls queryFn and manages the returned data; it does not provide base URL, headers, interceptors, or response transport features.
A shared queryClient is provided by default. If you need isolated caching, you can create an independent client:
JavaScriptconst client = createQueryClient({ cache: { adapter: 'memory', options: { maxSize: 300, ttl: 10 * 60_000, }, },});
createQuery Return Value
createQuery returns a function:
JavaScriptconst query = createQuery({ queryKey: ['products'], queryFn: fetchProducts,});query(); // Current dataquery.state; // Current statequery.refetch(); // Control method
This design keeps data reading simple while consolidating state and control methods in the same query object.
State Model
status describes the data result:
pending: No successful data yet.success: Successful data available.error: Most recent request failed.
fetchStatus describes the request process:
idle: No request in progress.fetching: Request in progress.
Thus, it can express the state of “having data but refreshing”:
JavaScriptquery.state.status === 'success';query.state.fetchStatus === 'fetching';query.state.isStale === true;
Common boolean states:
isPending: No successful data yet.isLoading: No displayable data and currently requesting.isFetching: Currently requesting.isStale: Current data has expired, or waiting for new results using old data.isSuccess: Currently has successful data.isError: Most recent request failed.isPaused: Query is currently disabled.
queryKey
queryKey is the request identity that determines caching, deduplication, and invalidation.
Arrays are recommended:
JavaScript['products', { page: 1, keyword: 'phone' }];
Object fields are stably sorted, so the following two keys are equivalent:
JavaScript['products', { page: 1, keyword: 'phone' }];['products', { keyword: 'phone', page: 1 }];
Array keys also support prefix invalidation:
JavaScriptqueryClient.invalidateQueries(['products']);
This can match:
JavaScript['products', 1];['products', 2];['products', { keyword: 'phone' }];
Cache Model
Cache record structure:
JavaScript{ data, queryKey, updatedAt, staleTime, invalidated, meta,}
Cache freshness:
JavaScriptisStale = invalidated || Date.now() - updatedAt >= staleTime;
Cache retention is managed by the selected adapter:
cache.options.ttlcontrols record retention time for every adapter.cache.options.maxSizecontrols maximum record count for the memory LRU adapter.staleTimecontrols data freshness duration.
Supported adapters:
memory: default adapter backed byvanilla-lru.cookie: persistent adapter backed byvanilla-create-storage.localStorage: persistent adapter backed byvanilla-create-storage.indexedDB: persistent adapter backed byvanilla-create-storage.
When fresh cache hits, the query uses the cache directly. When stale cache hits, the query can display old data first, then initiate background refresh.
Persistent adapters keep a memory shadow cache and hydrate records from the selected browser storage. Async query execution can wait for hydration; synchronous cache reads expose the current in-memory view.
Request Model
Request flow:
- Parse
queryKeyandenabled. - If fresh cache hits, write directly to state.
- If stale cache hits, display old data first, then make background request.
- If no displayable data, enter loading state.
- Client deduplicates pending requests by hash key.
- Execute
normalizeandselectafter successful request. - Write to state and cache.
- Retry according to retry strategy after request failure.
- Write to error state on final failure.
- Ignore results from expired requests via request ID.
Error Model
Default normalize supports { success, data, message, code } style responses.
JavaScript{ success: true, data: [] }{ success: false, message: 'No access', code: 'NO_ACCESS' }
success: false is converted to BusinessError.
You can also customize normalize:
JavaScriptcreateQuery({ normalize(response) { if (response.errno !== 0) { throw new Error(response.errmsg); } return { data: response.result }; }, queryFn,});
Extension Points
Common extension methods:
queryClient.subscribe(listener): Listen to cache and request events.onSuccess,onError,onSettled: Listen to individual query lifecycle.normalize: Unify business response structure.select: Derive final data from response data.- Custom
retry,retryDelay,shouldRetry: Control failure retry strategies.