@hecto/query
v1.1.0
Published
Typed async query cache with stale data, optimistic mutations, and hydration.
Readme
@hecto/query
Typed, framework-neutral fetch caching built on cancellable Tasks. Use it
when a value is opaque to the client: a profile, search result, dashboard
tile, or paged HTTP response.
import { createQueryClient, defineQuery } from '@hecto/query'
const client = createQueryClient()
const user = defineQuery({
key: (id: number) => ['user', id] as const,
fetch: async ({ params, signal }) =>
fetch(`/api/users/${params[0]}`, { signal }).then((response) => response.json()),
options: { staleTime: 30_000, retry: 2, backoff: 'exponential' },
})
await client.fetch(user, 42)
client.invalidate(['user'])defineQuery keeps keys and parameters typed. Entries deduplicate concurrent
fetches, retain stale values while refetching, use one focus/online listener
per client, and support cancellation, prefix invalidation, prefetching,
infinite pages, hydrated cache state, and serial or offline-paused mutations.
entry.fetch({ force: true }) bypasses freshness checks but still joins active
work. Add replace: true only when the active operation must be cancelled and
replaced.
Infinite refetches retain the first stored cursor and recompute each following
cursor from the newly fetched page, so changed server pagination stays valid.
Every mutateAsync call owns its completion, including while offline or queued
behind the same mutation scope. Resume settles that original promise; reset or
client disposal rejects it with CancelledError. Mutation retry counts
additional attempts and supports retryDelay plus an injected scheduler;
terminal callbacks run once after attempts finish.
Query keys support strings, booleans, finite numbers, null, arrays, and plain
records. Record keys are sorted, undefined record properties are omitted,
and undefined array items match null, following JSON normalization. Cycles,
dates, functions, symbols, bigints, maps, sets, class instances, and non-finite
numbers throw a TypeError.
How it differs from TanStack Query
- Query keys are typed tuples, not API-level hashes.
- Fetchers can return
Task<Result<T, E>>, so expected typed failures do not need to become exceptions. - Query values stay opaque. Identity, patches, optimistic rows, and live joins
belong in
@hecto/db. - It has no streamed SSR or cross-tab broadcast layer yet; add either only when this workspace has a concrete host for it.
Use framework bindings from @hecto/query/react, /vue, /solid,
/svelte, or /angular.
Native View ownership
@hecto/query/view is optional; the neutral Query entry does not import View.
Create the client at the request or application lifetime, then bind an observer inside a View owner:
import { useQuery } from '@hecto/query/view'
const user = useQuery(client, userById, () => [selectedId.get()], {
select: value => value.name,
})
// In a native binding: user.state().data, .status, .fetchStatus or .error.
// A user action can await user.refetch().The tuple accessor tracks parameter changes. A null query key skips observation and refetch.
The binding releases its own observer when its owner ends; it does not destroy the client or
cancel another consumer's request. destroy() provides earlier explicit release.
Seed a request-owned client before SSR and hydrate the application client from that snapshot
before attachment. client.getState(definition, ...params) reads an existing cache entry without
creating one. Server bindings read this snapshot without observing, pinning, fetching or scheduling
timers; missing queries render idle (or definition initial/placeholder data). Client observation
begins on mount. Server refetch() is inert: fetch data before entering the render scope.
