Query Core
A minimal server state management library for building reactive web applications.
Query Core
@web-loom/query-core is a lightweight, zero-dependency library for managing asynchronous data fetching, caching, and state management in JavaScript applications. It provides a simple yet powerful API to define data endpoints, subscribe to their state changes, and control data refetching and invalidation.
Features
- Declarative API: Define data endpoints with associated fetcher functions and options.
- Automatic Caching: Built-in support for
inMemory,localStorage, andindexedDBcaching, or provide your own custom cache provider. Default isinMemory. - State Management: Endpoints maintain their own state (data, loading, error, last updated).
- Subscription Model: Components can subscribe to endpoint state changes and reactively update.
- Automatic Refetching:
- Refetches stale data when a component subscribes.
- Refetches observed queries when the browser window becomes visible.
- Refetches observed queries when the network connection is restored.
- Manual Control: Methods to manually trigger refetches or invalidate cached data.
- Deep Cloning of Data: Ensures data immutability for subscribers by providing structured clones of the state's data.
Installation
npm install @web-loom/query-coreCore Concepts
QueryCore Instance
The main class you interact with. It manages all defined endpoints and global configurations.
import { QueryCore } from '@web-loom/query-core';
const queryCore = new QueryCore({
cacheProvider: 'indexedDB', // Default for all endpoints
defaultRefetchAfter: 5 * 60 * 1000, // Global default: refetch after 5 minutes
});Interfaces
QueryCoreOptions
Options to configure the QueryCore instance globally.
export interface QueryCoreOptions {
cacheProvider?: 'inMemory' | 'localStorage' | 'indexedDB' | CacheProvider; // Default: 'inMemory'
defaultRefetchAfter?: number; // Global default for refetchAfter (in milliseconds)
}cacheProvider: Specifies the default caching mechanism. Can be a string ('inMemory','localStorage','indexedDB') or a custom object implementing theCacheProviderinterface.defaultRefetchAfter: A global default (in milliseconds) indicating how long data is considered fresh before a refetch is attempted upon subscription or window focus.
EndpointOptions
Options to configure a specific endpoint, overriding global settings if provided.
export interface EndpointOptions {
refetchAfter?: number; // in milliseconds
cacheProvider?: 'inMemory' | 'localStorage' | 'indexedDB' | CacheProvider; // Override global cache provider
}refetchAfter: Endpoint-specific duration (in milliseconds) after which data is considered stale.cacheProvider: Endpoint-specific cache provider (can be'inMemory','localStorage','indexedDB', or a custom provider).
EndpointState<TData>
Represents the state of an endpoint.
export interface EndpointState<TData> {
data: TData | undefined;
isLoading: boolean;
isError: boolean;
error: any | undefined;
lastUpdated: number | undefined; // Timestamp of when data was last successfully fetched and cached
}data: The fetched data for the endpoint.undefinedif not yet fetched, or an error occurred.isLoading:trueif a fetch operation is currently in progress.isError:trueif the last fetch attempt resulted in an error.error: The error object ifisErroristrue.lastUpdated: Timestamp (fromDate.now()) of the last successful data fetch and cache.
API Reference
constructor(options?)
Creates a new QueryCore instance and seeds the default cache provider plus refetch policy.
const queryCore = new QueryCore({
cacheProvider: 'indexedDB',
defaultRefetchAfter: 5 * 60 * 1000,
});defineEndpoint<TData>(endpointKey, fetcher, options?)
Defines or redefines a data endpoint and initializes cached state. This call is async because cache providers may hydrate storage.
endpointKey: Unique key such as'posts'or'user/123'.fetcher: Async function that returns the required data.options: Overrides forrefetchAfteror cache provider.
await queryCore.defineEndpoint('allPosts', async () => {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}, {
refetchAfter: 10 * 60 * 1000,
cacheProvider: 'localStorage',
});Behavior
- Replaces the existing fetcher and configuration if the endpoint already exists.
- Loads cached state immediately, but does not fetch until you subscribe or refetch.
- Does not by itself make the endpoint "observed": the visibility and reconnect refetches only apply to endpoints that currently have at least one subscriber.
- It is
async—awaitit before subscribing or refetching.
Best Practice
- Register endpoints during app bootstrap.
- Use descriptive keys.
- Surface meaningful error messages inside the fetcher.
subscribe<TData>(endpointKey, callback)
Subscribes to state updates for the endpoint; the callback runs immediately and on every state change.
endpointKey: The endpoint identifier.callback: ReceivesEndpointState<TData>withdata,isLoading,isError,error, andlastUpdated.- Returns: Unsubscribe function.
const unsubscribe = queryCore.subscribe('users', (state) => {
if (state.isLoading) {
console.log('Loading users...');
} else if (state.isError) {
console.error('Error:', state.error);
} else if (state.data) {
console.log('Users:', state.data);
}
console.log('Last updated:', new Date(state.lastUpdated));
});
unsubscribe();Behavior
- Immediately emits the current state (possibly stale).
- Automatically refetches when stale or missing.
- Adds the endpoint to the observed set.
Best Practices
- Always unsubscribe on cleanup.
- Handle
isLoading,isError, and success paths. - Keep callbacks lightweight.
refetch<TData>(endpointKey, forceRefetch?)
Manually refetches data for the endpoint.
forceRefetch:trueignores staleness;falserespectsrefetchAfter.- Concurrent refetches for the same endpoint are deduplicated.
await queryCore.refetch('users');
await queryCore.refetch('users', true);Use Cases
- Refresh buttons, pull-to-refresh gestures, after mutations, periodic polling.
Error Handling
refetch never rejects: a failing fetcher sets isError and error on the endpoint state (and logs the error), so read failures from the state rather than wrapping the call in try/catch.
invalidate(endpointKey)
Clears cached data for an endpoint from every layer.
- Useful after logout, deletes, or when you want a clean state.
- Finish with
refetchif you need immediate data.
await queryCore.invalidate('users');
await queryCore.refetch('users', true);getState<TData>(endpointKey)
Returns the current snapshot without subscribing or refetching.
const state = queryCore.getState<User[]>('users');
if (state.data) {
console.log('Current count:', state.data.length);
}Default state (when undefined):
{
data: undefined,
isLoading: false,
isError: false,
error: undefined,
lastUpdated: undefined,
}
Use Cases
- Server-side rendering checks or logging.
- Conditional UI logic that needs instant access to cached values.
Usage Example
import { QueryCore } from '@web-loom/query-core';
// 1. Initialize QueryCore
const queryClient = new QueryCore({
defaultRefetchAfter: 60000, // Refetch data if older than 1 minute by default
});
// 2. Define an endpoint
async function fetchUserDetails(userId: string) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error(`Failed to fetch user ${userId}`);
return response.json();
}
await queryClient.defineEndpoint(
'userDetails/1',
() => fetchUserDetails('1'),
{ cacheProvider: 'localStorage' }, // Override global cache provider for this endpoint
);
// 3. Subscribe to endpoint state (e.g., in a UI component)
const unsubscribe = queryClient.subscribe('userDetails/1', (state) => {
if (state.isLoading) {
document.getElementById('user-name').textContent = 'Loading...';
} else if (state.isError) {
document.getElementById('user-name').textContent = `Error: ${state.error.message}`;
} else if (state.data) {
document.getElementById('user-name').textContent = state.data.name;
}
});
// 4. Manually trigger a refetch if needed
document.getElementById('refresh-button').onclick = () => {
queryClient.refetch('userDetails/1', true); // Force refetch
};
// 5. Invalidate data (e.g., after a user logs out or data becomes stale)
document.getElementById('logout-button').onclick = async () => {
await queryClient.invalidate('userDetails/1');
unsubscribe(); // Clean up subscription
};Cache Providers
QueryCore ships with three built-in cache providers and a small interface for custom storage.
InMemoryCacheProvider
- Fast, volatile cache stored in memory (default behavior).
- Zero persistence—data disappears on refresh.
- No size limits beyond available memory.
import { InMemoryCacheProvider } from '@web-loom/query-core';
const cache = new InMemoryCacheProvider();
const queryCore = new QueryCore({ cacheProvider: cache });Ideal for short-lived data, testing, or server-side rendering.
LocalStorageCacheProvider
- Persists data in browser
localStorageusing JSON serialization. - ~5–10MB quota per origin depending on the browser.
- Exposes the same Promise-based
CacheProviderAPI as the other providers (backed by synchronouslocalStorage); susceptible to quota errors. - Storage key:
QueryCore_${endpointKey}.
import { LocalStorageCacheProvider } from '@web-loom/query-core';
const cache = new LocalStorageCacheProvider();
const queryCore = new QueryCore({ cacheProvider: cache });Also available per endpoint via the { cacheProvider: 'localStorage' } option of defineEndpoint(key, fetcher, options).
Use cases: user preferences, small datasets, tokens, or cross-tab syncing.
IndexedDBCacheProvider
- Large, asynchronous persistence layer (~50MB+ quotas).
- Supports complex objects without blocking the main thread.
- Great for offline-first or data-heavy applications.
import { IndexedDBCacheProvider } from '@web-loom/query-core';
const cache = new IndexedDBCacheProvider(); // no arguments — uses the 'QueryCoreDB' database
const queryCore = new QueryCore({ cacheProvider: cache });The database name is fixed (QueryCoreDB), so apps on the same origin share it. Handle quota rejections and clean stale entries periodically.
Custom Cache Providers
Implement the CacheProvider interface to plug in any storage backend (Redis, compression, etc.).
interface CachedItem<TData> {
data: TData;
lastUpdated: number;
}
interface CacheProvider {
get<TData>(key: string): Promise<CachedItem<TData> | undefined>;
set<TData>(key: string, item: CachedItem<TData>): Promise<void>;
remove(key: string): Promise<void>;
clearAll?(): Promise<void>;
}const myCustomCache = new MyCustomCacheProvider();
const queryCore = new QueryCore({ cacheProvider: myCustomCache });This interface is intentionally small so you can wrap Redis, compression layers, or any storage you need.
Automatic Behaviors
These behaviors trigger automatically for every observed endpoint.
Stale-While-Revalidate
- Emit cached data immediately (if available).
- Compare
lastUpdatedwithrefetchAfter. - Refetch in the background if stale.
- Push fresh data to subscribers.
Benefit: instant hydration plus silent refreshes.
Window Visibility Refetch
- Listen for
visibilitychangeand act when the document becomes visible. - Refetch endpoints that have subscribers and whose data is stale (respects
refetchAfter). - Update subscribers with the latest values.
Disable per endpoint with refetchAfter: Infinity if you need manual control.
Network Reconnect Refetch
- Detect the network coming online (
onlineevent). - Force-refetch every endpoint that has subscribers (ignores
refetchAfter). - Refresh the cache and UI.
Great for mobile/offline apps recovering from dropped connections.
Usage Examples
React useQuery hook
import { useState, useEffect, useCallback } from 'react';
import { QueryCore, type EndpointState } from '@web-loom/query-core';
function useQuery<TData>(queryCore: QueryCore, endpointKey: string) {
const [state, setState] = useState<EndpointState<TData>>(
() => queryCore.getState<TData>(endpointKey)
);
useEffect(() => {
const unsubscribe = queryCore.subscribe<TData>(endpointKey, setState);
return unsubscribe;
}, [queryCore, endpointKey]);
const refetch = useCallback(
(force = false) => queryCore.refetch(endpointKey, force),
[queryCore, endpointKey]
);
const invalidate = useCallback(
() => queryCore.invalidate(endpointKey),
[queryCore, endpointKey]
);
return { ...state, refetch, invalidate };
}import { FC } from 'react';
import useQuery from './hooks/useQuery';
import { queryCore } from './queryCore';
const UserList: FC = () => {
const { data, isLoading, isError, error, refetch, invalidate } =
useQuery<User[]>(queryCore, 'users');
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error?.message}</div>;
return (
<>
<button onClick={() => refetch(true)}>Refresh</button>
<button onClick={invalidate}>Clear Cache</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</>
);
};Best Practices
- Define endpoints early, keep keys descriptive, and centralize fetchers.
- Combine
defineEndpointwithsubscribe(or helper hooks) instead of ad-hoc logic. - Handle
isLoading,isError, and success states in every subscriber. - Unsubscribe/cleanup when components unmount.
- Set
refetchAfterbased on data volatility. - Invalidate after destructive actions before refetching to avoid stale data.
- Rely on endpoint state (
isError/error) for error display —refetchdoesn't reject.
MVVM Core integration
@web-loom/mvvm-core's source includes a QueryStateModel/QueryStateModelView pair intended to wrap Query Core endpoints inside viewmodels — mapping endpointState into @web-loom/signals-core signals (data$, isLoading$, error$) and exposing commands (refetchCommand, invalidateCommand) for UI actions. These are not currently exported from mvvm-core's public entry point, so treat them as internal/unstable — see Models: QueryStateModel.
Until they're exported, compose BaseModel with Query Core directly: subscribe to the endpoint's state inside a BaseModel subclass and call setData/setLoading/setError from that subscription, then call refetch()/invalidate() on the underlying QueryCore instance from your ViewModel's commands. This keeps the viewmodel as the boundary between UI components and the shared cache, while Query Core handles caching and deduplication.