npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sqonde/knoten

v1.5.1

Published

Minimal React data fetching: useQuery, useMutation, invalidate + an imperative cache API.

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 zustand

react 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. Call refetch()/invalidate() if you want a real fetch afterwards.
  • interval — poll (ms) while the tab is visible & online.
  • enabled (default true) — gate the query on/off.
  • staleTime (ms, default Infinity) — auto-refetch a cached entry on mount/re-enable/key-change once it is older than this. The default is Infinity on purpose (cache-first — never auto-refetch on mount), the opposite of TanStack's 0. refetch(), invalidate(), and polling always fetch, ignoring staleTime.
  • 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 than maxAge, the entry is dropped. Without it the cache is permanent. (This is data-age eviction, not a TanStack gcTime inactivity countdown.)
  • retry (default 0/off) — retry a failed fetch N times; true means 3 (never Infinity). Retries are intra-fetch: an exhausted error is cached, not retried again on remount. AbortErrors are never retried. Tune the wait with retryDelay (ms or attempt => 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 use

The 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.