Web Loom logoWeb.loom
Published PackagesQuery Core

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, and indexedDB caching, or provide your own custom cache provider. Default is inMemory.
  • 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-core

Core 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 the CacheProvider interface.
  • 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. undefined if not yet fetched, or an error occurred.
  • isLoading: true if a fetch operation is currently in progress.
  • isError: true if the last fetch attempt resulted in an error.
  • error: The error object if isError is true.
  • lastUpdated: Timestamp (from Date.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 for refetchAfter or 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: Receives EndpointState<TData> with data, isLoading, isError, error, and lastUpdated.
  • 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: true ignores staleness; false respects refetchAfter.
  • 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 refetch if 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 localStorage using 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

  1. Emit cached data immediately (if available).
  2. Compare lastUpdated with refetchAfter.
  3. Refetch in the background if stale.
  4. Push fresh data to subscribers.

Benefit: instant hydration plus silent refreshes.

Window Focus Refetch

  1. Listen for browser focus.
  2. Refetch observed endpoints whose data is stale.
  3. Update subscribers with the latest values.

Disable per endpoint with refetchAfter: Infinity if you need manual control.

Network Reconnect Refetch

  1. Detect the network coming online.
  2. Refetch all observed endpoints.
  3. 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 defineEndpoint with subscribe (or helper hooks) instead of ad-hoc logic.
  • Handle isLoading, isError, and success states in every subscriber.
  • Unsubscribe/cleanup when components unmount.
  • Set refetchAfter based on data volatility.
  • Invalidate after destructive actions before refetching to avoid stale data.
  • Wrap refetches in try/catch and 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:

  1. Define an endpoint and start observing it within the model.
  2. Map endpointState into RxJS observables like data$, isLoading$, and error$.
  3. 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.

Was this helpful?
Web Loom logoWeb.loom
Copyright © Web Loom. All rights reserved.