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
[object Object]
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.
[object Object]
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).
[object Object]
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.
- Marks the endpoint as observed for automatic behaviors.
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
Wrap in try/catch; failures update isError and error.
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.
- Synchronous API: easy to reason about but 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 defineEndpoint({ cacheProvider: 'localStorage' }).
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('my-app-db');
const queryCore = new QueryCore({ cacheProvider: cache });Tips: give each app a unique database name, 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 Focus Refetch
- Listen for browser
focus. - Refetch observed endpoints whose data is stale.
- 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.
- Refetch all observed endpoints.
- 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, { 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.
- Wrap refetches in
try/catchand rely on endpoint state for error display.
MVVM Core integration
@web-loom/mvvm-core provides QueryStateModel and QueryStateModelView to wrap Query Core endpoints inside viewmodels. These helpers:
- Define an endpoint and start observing it within the model.
- Map
endpointStateinto RxJS observables likedata$,isLoading$, anderror$. - Expose commands (
refetchCommand,invalidateCommand) for UI actions.
This keeps the viewmodel as the boundary between UI components and the shared cache, while Query Core handles caching and deduplication.