@sqonde/knoten
v1.5.1
Published
Minimal React data fetching: useQuery, useMutation, invalidate + an imperative cache API.
Maintainers
Readme
Knoten
Hi 👋 - Knoten ("knot" in German) is a small, friendly little library that
gives React the data-fetching basics: useQuery, useMutation, and
invalidate - plus a small imperative escape hatch for use outside components.
Nothing magical. Just data fetching, the way I always wished it were.
Why does this exist?
Honest answer: I'm a happy little Zustand fanboy 💚. When I needed proper data fetching in a React app - caching, polling, invalidation - I kept reaching for TanStack Query but kept wishing it just was Zustand underneath. So I built it that way.
Knoten is small, transparent, and sits comfortably on top of the state library I already love. No new mental model. No clever tricks. A few hundred lines of code that you can read in one sitting and reason about over a coffee.
Knoten was carved out of the dashboard for Messwerk, the push-based monitoring system I build for elbtik.de - my small freelance network-infrastructure business. The dashboard needed proper data fetching, and rather than reach for a heavy library, I wanted something small that I could fully understand. This is the result, kindly extracted so others can use it too.
Install
bun add @sqonde/knoten react zustandreact and zustand are peer dependencies - please install them yourself,
so we don't end up with two competing copies of either floating around in
your bundle.
useQuery
import { useQuery } from '@sqonde/knoten';
function Users() {
const { data, isLoading, error } = useQuery(
['users'],
(signal) => fetch('/api/users', { signal }).then((r) => r.json()),
{ interval: 5000 }, // poll every 5s while the tab is visible & online
);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}Options:
initialData— seeds the cache as already-fetched data and skips the initial fetch. Callrefetch()/invalidate()if you want a real fetch afterwards.interval— poll (ms) while the tab is visible & online.enabled(defaulttrue) — gate the query on/off.staleTime(ms, defaultInfinity) — auto-refetch a cached entry on mount/re-enable/key-change once it is older than this. The default isInfinityon purpose (cache-first — never auto-refetch on mount), the opposite of TanStack's0.refetch(),invalidate(), and polling always fetch, ignoringstaleTime.select(data)— project the fetched data into a derived value. Keep it pure and non-throwing; it only runs when data exists and stays referentially stable while the raw data is unchanged.maxAge(ms) — opt-in lazy eviction: when the last consumer of a key detaches (unmount or key change) and its data is older thanmaxAge, the entry is dropped. Without it the cache is permanent. (This is data-age eviction, not a TanStackgcTimeinactivity countdown.)retry(default0/off) — retry a failed fetch N times;truemeans 3 (never Infinity). Retries are intra-fetch: an exhausted error is cached, not retried again on remount.AbortErrors are never retried. Tune the wait withretryDelay(ms orattempt => ms; default capped exponential backoff).
Result: data, error, isLoading (initial load),
isRefetching (background refresh), isFetching (either), refetch.
Polling pauses when the tab is hidden or you go offline, and picks back up the moment you're back. It tries hard not to waste your bandwidth or your battery.
Reading & writing the cache
import { getQueryData, setQueryData } from '@sqonde/knoten';
getQueryData<User[]>(['users']); // read (unchecked cast, may be undefined)
setQueryData<User[]>(['users'], (old) => // write a value or an updater
[...(old ?? []), newUser]);Both take the same array keys as useQuery, work outside React, and a write
re-renders any mounted query on that key. Heads up: a setQueryData on a key
with an in-flight fetch is overwritten when that fetch resolves, and writing to
a key before its useQuery mounts seeds it (suppressing the initial fetch, like
initialData).
Fetching outside React
Sometimes you need the data before any component renders - an auth guard, a
route loader, a keyboard shortcut handler. ensureQueryData and fetchQuery
are useQuery's imperative cousins: same keys, same cache, same single-flight
request per key.
import { ensureQueryData, fetchQuery } from '@sqonde/knoten';
// Route guard: make sure the session is loaded before entering the route.
// Fresh cached data returns immediately; otherwise this JOINS the in-flight
// request for the key (or starts one) - never a duplicate fetch.
const session = await ensureQueryData(['session'], fetchSession);
// Force a fresh fetch (supersedes any in-flight request, like refetch()).
const user = await fetchQuery(['user', id], () => fetchUser(id));Both resolve with the data and reject with your fetcher's error, unchanged.
Whatever they fetch lands in the cache: a mounted useQuery on the same key
re-renders with it, and one mounting later reuses it without fetching. Options:
retry/retryDelay (both), staleTime (only ensureQueryData, default
Infinity - cache-first). Two things to know: ensureQueryData on a key that
cached an error fetches again (an explicit call may retry), and invalidate()
only reaches mounted hooks - purely imperative keys refetch via fetchQuery.
useMutation + invalidate
import { useMutation } from '@sqonde/knoten';
function CreateUser() {
const { mutate, isLoading } = useMutation(
(name: string) =>
fetch('/api/users', { method: 'POST', body: JSON.stringify({ name }) })
.then((r) => r.json()),
{
invalidates: ['users'], // refetches every useQuery whose key starts with ['users']
onSuccess: (user) => console.log('created', user),
},
);
return (
<button disabled={isLoading} onClick={() => mutate('Alice')}>
Add Alice
</button>
);
}Result: mutate(variables) (returns the result, or undefined on error or
cancel), isLoading, error, data (the last successful result), isSuccess,
reset() (clears error, data, and loading state back to idle), and
cancel() (aborts in-flight mutate() calls — see below).
Optimistic updates: onMutate(variables) runs before the mutator and its
return value is passed as context to onSuccess(data, variables, context),
onError(error, variables, context), and onSettled(data, error, variables,
context). Use it with the cache API to apply an optimistic write and keep a
rollback snapshot:
useMutation(renameUser, {
onMutate: ({ id, name }) => {
const prev = getQueryData<User[]>(['users']);
setQueryData<User[]>(['users'], (us) =>
(us ?? []).map((u) => (u.id === id ? { ...u, name } : u)));
return { prev }; // becomes `context`
},
onError: (_err, _vars, context) => setQueryData(['users'], context?.prev), // roll back
invalidates: ['users'], // fires before onSuccess
});If onMutate throws, the mutator is skipped and onError/onSettled fire with
context: undefined. (Type note: UseMutationOptions is <T, E, V, C> — V/C
come after E so existing UseMutationOptions<T, MyError> annotations keep
working; the hook itself is useMutation<T, V, E, C>.)
Cancellation: the mutator receives an AbortSignal as its second argument
(type Mutator<T, V>) — pass it to fetch() and cancel() aborts the request:
const { mutate, isLoading, cancel } = useMutation((name: string, signal) =>
fetch('/api/users', { method: 'POST', body: JSON.stringify({ name }), signal })
.then((r) => r.json()),
);A cancelled mutation settles silently: mutate() resolves undefined, nothing
is written to error, and onSuccess/onError don't fire. Only
onSettled(undefined, error, variables, context) fires, with a DOMException
AbortError as error (recognize it via error?.name === 'AbortError') — roll
back optimistic updates there. If the request was already sent, invalidates
still runs: the server may have committed, so the cache refetches the truth.
reset() does not cancel — it only clears state; a running mutation
completes normally. The same swallow-and-settle rule applies when the mutator
aborts itself (e.g. its own timeout controller).
You can also call invalidate(['admin']) directly - it refetches every
active query whose key starts with ['admin', ...] (e.g. ['admin', 'users'],
['admin', 'tenants']). Prefix matching keeps things ergonomic.
Typing errors
Whatever your fetcher or mutator throws lands in error as-is — no
normalization, no wrapping. By default error is typed as Error | null,
but you can narrow it to your own class via the second generic:
class ApiError extends Error {
requestId: string | null;
// …
}
const { error } = useQuery<User[], ApiError>(['users'], fetchUsers);
// error is ApiError | null — error?.requestId is yours to useThe same works for useMutation<Data, Vars, ApiError>(…).
Knoten stays out of your way - there's no built-in fetch wrapper and no
CSRF handling. Retries exist but are strictly opt-in (retry), off by default.
Bring your own; Knoten will happily get along with it.
Contributing
I'd love your help. 🤝
If you're a human with an idea, a bug, a question, or just a friendly hello - open an issue or a PR. Knoten is intentionally small, but it can always be a little kinder, a little clearer, a little better. Tiny improvements are very welcome.
If you're an AI agent helping someone work on this - welcome aboard, you're part of the team too. The same rules apply: be honest about what you change, write a real test for it, and keep the surface area small. The goal is something humans and AI can both enjoy maintaining together.
About me
Hey, I'm Moritz. elbtik.de is my small freelance network-infrastructure business, where I design, install, and monitor networks for restaurants, hotels, medical practices, and coworking spaces. Knoten is one of the little tools that came out of that work, and elbtik is very much a Herzensprojekt of mine. If you found Knoten helpful, swing by and say hi - I'd love to hear what you're building.
License
MIT - use it, fork it, share it, make it yours.
