Query

An async cache for server state — deduplication, staleness, retries and invalidation, with no dependencies.

import { ... } from '@corndev/query'

Install

npm install @corndev/query

Every observer watchQuery returns exposes { getState, subscribe } — the same contract @corndev/store uses — so a query can be narrowed by derive, pushed into a Corn UI controller by bind, or read by @corndev/store-react’s useSelector, with no adapter and no dependency between the packages. bind and derive ship in both packages from one source, so a vanilla page installing only this one still has its bridge. Query keys are arrays hashed with sorted object keys, so { a, b } and { b, a } are one query rather than two requests.

Quick start — vanilla JS

No framework and no build step. This is the whole integration.

import { createQueryClient, bind } from '@corndev/query';

const client = createQueryClient();

const todos = client.watchQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: ({ queryKey, signal }) => fetch(`/api/todos?page=${queryKey[1].page}`, { signal }).then(r => r.json()),
  staleTime: 30_000,
});

// Reading it is what starts it: the first subscriber triggers the fetch, so the
// loading state is on screen in the same tick — there is no empty flash.
const list = document.querySelector('#todos');
const item = text => Object.assign(document.createElement('li'), { textContent: text });
bind(todos, state => {
  // Server data is text: elements and textContent, never innerHTML.
  list.replaceChildren(
    ...(state.isFetching ? [item('Loading…')] : (state.data ?? []).map(todo => item(todo.title))),
  );
});

Why this exists

Client state is yours: you set it, you know when it changed. Server state is borrowed — it arrives late, it goes stale without telling you, it is shared between parts of the page, and two components asking for it in the same tick should make one request. Modelling that with a boolean in a reducer is where most isLoading bugs come from.

@corndev/query is that model in about 2.4 KB min+gzip with nothing underneath it. Like Store, it is not a Corn UI component: no CSS, no markup, no import of @corndev/ui, and no import of Store either.

Query keys

A key is an array, and its hash sorts object keys before serialising — so ['todos', { a, b }] and ['todos', { b, a }] are one query rather than two requests. Filters match by prefix, which is what lets invalidateQueries({ queryKey: ['todos'] }) reach every page of a list without naming them.

Anything a queryFn closes over belongs in the key. The cache is keyed on the key alone, so a value that changes the result but not the key produces a stale hit — which is why queryFn is handed { queryKey, signal }, so it can read its inputs back out of the key rather than closing over them.

Reading is what starts it

watchQuery does not fetch until something subscribes. That makes the loading state visible in the same tick as the bind — there is no empty flash to design around — and it means an unobserved query costs nothing. Once the last observer leaves, the entry is evicted after gcTime.

status and isFetching are separate on purpose. status is what you have; isFetching is what is happening. A background refresh of data you already hold is status: 'success' and isFetching: true, which is what lets you show the rows and a quiet spinner rather than a blank screen.

A failed refetch keeps the data it already had. A list that blanks itself because a background refresh failed is worse than a list showing slightly old rows, so status reports the failure while data stays put.

Recipes

A whole page, no framework

import { createQueryClient, bind } from '@corndev/query';

const client = createQueryClient({ defaultOptions: { staleTime: 30_000 } });
const list = document.querySelector('#todos');

const todos = client.watchQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) => fetch('/api/todos', { signal }).then(r => r.json()),
});

// Server data is text: build elements and set textContent, never innerHTML.
const item = text => Object.assign(document.createElement('li'), { textContent: text });

bind(todos, state => {
  if (state.status === 'error' && !state.data) return void (list.textContent = state.error.message);
  list.replaceChildren(
    ...(state.isFetching && !state.data ? [item('Loading…')] : state.data.map(todo => item(todo.title))),
  );
});

Pagination

The page number belongs in the key, so each page is its own cache entry and going back is instant:

let page = 1;
let observer;
let unbind;

function show(next) {
  page = next;
  unbind?.();
  observer?.destroy();
  observer = client.watchQuery({
    queryKey: ['todos', { page }],
    queryFn: ({ queryKey, signal }) =>
      fetch(`/api/todos?page=${queryKey[1].page}`, { signal }).then(r => r.json()),
  });
  unbind = bind(observer, render);
}

One invalidateQueries({ queryKey: ['todos'] }) still refreshes every page, because filters match by prefix.

Dependent queries

enabled: false holds a query back until it has what it needs:

const projects = client.watchQuery({
  queryKey: ['projects', user.getState().data?.id],
  queryFn: ({ queryKey }) => fetchProjects(queryKey[1]),
  enabled: Boolean(user.getState().data),
});

Optimistic updates with rollback

onMutate runs first and its return value reaches onError and onSettled as context — which is how the rollback gets carried:

const toggle = createMutation({
  mutationFn: todo => api.patch(`/todos/${todo.id}`, { done: !todo.done }),
  onMutate: todo => {
    const previous = client.getQueryData(['todos']);
    client.setQueryData(['todos'], items =>
      items.map(t => (t.id === todo.id ? { ...t, done: !t.done } : t)),
    );
    return { previous };
  },
  onError: (error, todo, context) => client.setQueryData(['todos'], context.previous),
  onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
});

A submit button that disables itself

pending is set synchronously, before anything is awaited, so a click handler can disable its button in the same tick as the click:

bind(toggle, state => (button.disabled = state.status === 'pending'));
button.addEventListener('click', () => toggle.mutate(todo).catch(() => {}));

mutate rejects if the mutation does, so a vanilla call site needs a .catch; the error is also on the state.

Showing a Corn UI skeleton

import { createSkeleton } from '@corndev/ui';
import { bind } from '@corndev/query';

const skeleton = createSkeleton(document.querySelector('#rows'));
bind(todos, skeleton, state => ({ loading: state.isFetching }));

bind and derive ship in this package as well as in Store — they belong to the contract rather than to either library, so installing this one alone still gets you the bridge.

Polling, prefetching, cancelling

const timer = setInterval(() => observer.refetch(), 5_000);        // no refetchInterval option: a timer is yours
link.addEventListener('mouseenter', () =>                          // warm the cache before navigation
  client.fetchQuery({ queryKey: ['todo', id], queryFn: fetchTodo, staleTime: 60_000 }));

Pass the signal your queryFn receives to fetch, and removeQueries genuinely aborts the request rather than merely ignoring its result.

Coming from TanStack Query

useQuery, useMutation, useQueryClient and QueryClientProvider take the shapes you would expect, and queryKey / queryFn / staleTime / gcTime / retry / select / enabled mean what they mean there.

What is missing is deliberate: no window-focus refetching, no infinite queries, no suspense integration, no persisters, and no devtools panel — client.inspect() and client.subscribe() are the whole of the introspection surface. Those omissions are the price of 2.4 KB. If you need infinite queries and a devtools panel, TanStack Query is the better tool and this one will not pretend otherwise.

With Corn UI

Neither package imports the other. bind needs getState/subscribe on one side and an update(options) method on the other, which is what every Corn UI create* controller returns.

// Show a Corn UI skeleton while the query is in flight — again with no
// dependency in either direction. Every Corn UI create* controller has an
// update() method, which is the whole of what bind needs on that side.
import { createSkeleton } from '@corndev/ui';
import { bind } from '@corndev/query';

const skeleton = createSkeleton(document.querySelector('#rows'));
bind(todos, skeleton, state => ({ loading: state.isFetching }));

React

npm install @corndev/query @corndev/query-react
import { QueryClientProvider, useQuery, useMutation, useQueryClient } from '@corndev/query-react';

function Todos() {
  const { data, status, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: ({ signal }) => fetch('/api/todos', { signal }).then(r => r.json()),
  });

  const client = useQueryClient();
  const { mutate, isPending } = useMutation({
    mutationFn: todo => fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) }),
    onSuccess: () => client.invalidateQueries({ queryKey: ['todos'] }),
  });

  if (status === 'pending') return <p>Loading…</p>;
  return <button disabled={isPending} onClick={() => mutate({ title: 'New' })}>Add</button>;
}

API

createQueryClient(options)

Create a query client.

Returns object.

OptionTypeDefaultDescription
defaultOptionsQueryDefaultsundefinedDefaults for every query on this client.

client.fetchQuery(options)

Fetch a query and resolve with its data, serving the cache when it is fresh.

Returns Promise<*>.

OptionTypeDefaultDescription
queryKeyArrayrequiredCache key.
queryFn(context: { queryKey: Array, signal: AbortSignal }) => Promise<*>requiredThe request.
staleTimenumber0Milliseconds the cached value counts as fresh.
retrynumber|((attempt: number, error: Error) => boolean)3Retry count, or a predicate.
retryDelaynumber|((attempt: number, error: Error) => number)undefinedBackoff in ms.

client.watchQuery(options)

Watch a query. Reading it is what starts it.

Returns QueryObserver.

OptionTypeDefaultDescription
enabledbooleantrueSet false to hold the query back until it has what it needs.
select(data: *) => *undefinedReshape data for this observer only; the cache keeps the raw value.
initialData*undefinedSeed the cache, treated as a real success.
placeholderData*undefinedShown while pending. Never written to the cache.
gcTimenumber300000Milliseconds an unobserved entry survives before eviction.

client.getQueryData(queryKey)

Read cached data without fetching.

Returns *.

client.getQueryState(queryKey)

Full cached state for a key, for anything the data alone cannot answer.

Returns QueryState|undefined.

client.setQueryData(queryKey, updater)

Write cached data directly — an optimistic update, or a result that arrived by another route such as a mutation response or a socket.

Returns *.

client.invalidateQueries(filters)

Mark queries stale, and refetch the ones being watched. Keys match by prefix, so ['todos'] reaches ['todos', { page: 2 }].

Returns Promise<void>.

OptionTypeDefaultDescription
queryKeyArrayundefinedPrefix to match. Omit to invalidate everything.
exactbooleanundefinedMatch the whole key instead of a prefix.
refetchbooleantrueRefetch matching queries that have observers.

client.removeQueries(filters)

Drop cached entries entirely, aborting anything in flight.

Returns number.

OptionTypeDefaultDescription
queryKeyArrayundefinedPrefix to match. Omit to remove everything.
exactbooleanundefinedMatch the whole key instead of a prefix.

client.subscribe(listener)

Watch the cache as a whole — for a devtools panel or a global spinner.

Returns () => void.

client.inspect()

Snapshot of every cached query, newest state included.

Returns Array<{ queryKey: Array, state: QueryState, observers: number }>.

createMutation(options)

Create a mutation. onMutate runs before the request and its return value is handed back to onError and onSettled as context — which is how an optimistic update carries the rollback it will need.

Returns { getState: () => MutationState, subscribe: (listener: () => void) => () => void, mutate: (variables?: *) => Promise<*>, reset: () => void }.

OptionTypeDefaultDescription
mutationFn(variables: *) => Promise<*>requiredThe request.
onMutate(variables: *) => *undefinedRuns first; its return value becomes context.
onSuccess(data: *, variables: *, context: *) => voidundefinedRan on success.
onError(error: Error, variables: *, context: *) => voidundefinedRan on failure.
onSettled(data: *, error: Error|null, variables: *, context: *) => voidundefinedRan either way.
retrynumber|((attempt: number, error: Error) => boolean)0Retries. Defaults to none: writes are not idempotent.
retryDelaynumber|((attempt: number, error: Error) => number)undefinedBackoff in ms.

hashKey(key)

Stable string form of a query key.

Returns string.

matchesKey(filterKey, queryKey, exact)

Does a query key fall under a filter key? Filters match by prefix, so ['todos'] matches ['todos', { page: 2 }]. That is what makes invalidateQueries({ queryKey: ['todos'] }) reach every page of a list without naming them.

Returns boolean.

bind(source, target, select, options)

Push a source’s value into a target whenever it changes.

Returns () => void.

OptionTypeDefaultDescription
equals(a: *, b: *) => booleanundefinedSkip the update when this returns true. Defaults to Object.is.
immediatebooleantruePush the current value once at bind time.

derive(source, select, options)

Wrap a source in a narrower one that only notifies when its slice changes. The result satisfies the same contract as its input, so it can be handed to bind, to useSelector, or to another derive.

Returns Source.

OptionTypeDefaultDescription
equals(a: *, b: *) => booleanundefinedChange test. Defaults to Object.is.

React API — @corndev/query-react

QueryClientProvider(props)

Provide a query client to the tree below.

Returns import('react').ReactElement.

OptionTypeDefaultDescription
clientobjectrequiredA client from createQueryClient.
childrenimport('react').ReactNoderequiredSubtree that may run queries.

useQueryClient()

The client from the nearest provider — for imperative work such as setQueryData in a mutation callback.

Returns object.

useQuery(options)

Run a query and re-render as it settles. The observer is rebuilt when the query key or enabled changes — and only then. queryFn is a new closure on every render, so anything it depends on must appear in queryKey, exactly as the cache requires anyway.

Returns import('./client.js').QueryState & { isPending: boolean, isSuccess: boolean, isError: boolean, refetch: () => Promise<*> }.

useMutation(options)

Run a mutation and re-render as it settles.

Returns import('./mutation.js').MutationState & { isIdle: boolean, isPending: boolean, isSuccess: boolean, isError: boolean, mutate: (variables?: *) => void, mutateAsync: (variables?: *) => Promise<*>, reset: () => void }.