Query
⚡️ Powerful data fetching library for Nano Stores. TS/JS. Framework agnostic.
Install / Use
/learn @nanostores/QueryREADME
Nano Stores Query
<img align="right" width="92" height="92" title="Nano Stores logo" src="https://nanostores.github.io/nanostores/logo.svg">
A tiny data fetcher for Nano Stores.
- Small. Less than 2 Kb (minified and gzipped).
- Familiar DX. If you've used
swrorreact-query, you'll get the same treatment, but for 10-20% of the size. - Built-in cache.
stale-while-revalidatecaching from HTTP RFC 5861. User rarely sees unnecessary loaders or stale data. - Revalidate cache. Automatically revalidate on interval, refocus, network recovery. Or just revalidate it manually.
- Nano Stores first. Finally, fetching logic outside of components. Plays nicely with store events, computed stores, router, and the rest.
- Transport agnostic. Use GraphQL, REST codegen, plain fetch or anything, that returns Promises (Web Workers, SubtleCrypto, calls to WASM, etc.).
Install
npm install nanostores @nanostores/query
Usage
See Nano Stores docs about using the store and subscribing to store’s changes in UI frameworks.
Context
First, we define the context. It allows us to share the default fetcher implementation and general settings between all fetcher stores, and allows for simple mocking in tests and stories.
// store/fetcher.ts
import { nanoquery } from '@nanostores/query';
export const [createFetcherStore, createMutatorStore] = nanoquery({
fetcher: (...keys) => fetch(keys.join('')).then((r) => r.json()),
});
Second, we create the fetcher store. createFetcherStore returns the usual atom() from Nano Stores, that is reactively connected to all stores passed as keys. Whenever the $currentPostId updates, $currentPost will call the fetcher once again.
// store/posts.ts
import { createFetcherStore } from './fetcher';
export const $currentPostId = atom('');
export const $currentPost = createFetcherStore<Post>(['/api/post/', $currentPostId]);
Third, just use it in your components. createFetcherStore returns the usual atom() from Nano Stores.
Note: before any component subscribes to the store, its value is
{ loading: false }with nodataorerror. Once a component subscribes (viauseStore), the fetcher fires andloadingbecomestrue. Always checkdatafirst to handle this correctly.
// components/Post.tsx
const Post = () => {
const { data, error, loading } = useStore($currentPost);
if (data) return <div>{data.content}</div>;
if (error) return <>Failed to load</>;
if (loading) return <>Loading...</>;
return null;
};
createFetcherStore
export const $currentPost = createFetcherStore<Post>(['/api/post/', $currentPostId]);
It accepts two arguments: key input and fetcher options.
type NoKey = null | undefined | void | false;
type SomeKey = string | number | true;
type KeyInput = SomeKey | Array<SomeKey | ReadableAtom<SomeKey | NoKey> | FetcherStore>;
Under the hood, nanoquery will get the SomeKey values and pass them to your fetcher like this: fetcher(...keyParts). Few things to notice:
- if any atom value is either
NoKey, we never call the fetcher—this is the conditional fetching technique we have; - if you had
SomeKeyand then transitioned toNoKey, store'sdatawill be also unset; - you can, in fact, pass another fetcher store as a dependency! It's extremely useful, when you need to create reactive chains of requests that execute one after another, but only when previous one was successful. In this case, if this fetcher store has loaded its data, its key part will be the concatenated
keyof the store. See this example.
type Options = {
// The async function that actually returns the data
fetcher?: (...keyParts: SomeKey[]) => Promise<unknown>;
// How much time should pass between running fetcher for the exact same key parts
// default = 4000 (=4 seconds; provide all time in milliseconds)
dedupeTime?: number;
// Lifetime for the stale cache. If present, stale cache will be shown to a user.
// Cannot be less than `dedupeTime`.
// default = Infinity
cacheLifetime?: number;
// If we should revalidate the data when the window focuses
// default = false
revalidateOnFocus?: boolean;
// If we should revalidate the data when network connection restores
// default = false
revalidateOnReconnect?: boolean;
// If we should run revalidation on an interval
// default = 0, no interval
revalidateInterval?: number;
// Error handling for specific fetcher store. Will get whatever fetcher function threw
onError?: (error: any) => void;
// A function that defines a timeout for automatic invalidation in case of an error
// default — set to exponential backoff strategy
onErrorRetry?: OnErrorRetry | null;
}
The same options can be set on the context level where you actually get the
createFetcherStore.
createMutatorStore
Mutator basically allows for 2 main things: tell nanoquery what data should be revalidated and optimistically change data. From interface point of view it's essentially a wrapper around your async function with some added functions.
It gets an object with 3 arguments:
datais the data you pass to themutatefunction;invalidateandrevalidate; more on them in section How cache worksgetCacheUpdaterallows you to get current cache value by key and update it with a new value. The key is also revalidated by default.
export const $addComment = createMutatorStore<Comment>(
async ({ data: comment, revalidate, getCacheUpdater }) => {
// You can either revalidate the author…
revalidate(`/api/users/${comment.authorId}`);
// …or you can optimistically update current cache.
const [updateCache, post] = getCacheUpdater(`/api/post/${comment.postId}`);
updateCache({ ...post, comments: [...post.comments, comment] });
// Even though `fetch` is called after calling `revalidate`, we will only
// revalidate the keys after `fetch` resolves
return fetch('…')
}
);
The usage in component is very simple as well:
const AddCommentForm = () => {
const { mutate, loading, error } = useStore($addComment);
return (
<form
onSubmit={(e) => {
e.preventDefault();
mutate({ postId: "…", text: "…" });
}}
>
<button disabled={loading}>Send comment</button>
{error && <p>Some error happened!</p>}
</form>
);
};
createMutatorStore accepts an optional second argument with settings:
type MutationOptions = {
// Error handling for specific fetcher store. Will get whatever mutation function threw
onError?: (error: any) => void;
// Throttles all subsequent calls to `mutate` function until the first call finishes.
// default: true
throttleCalls?: boolean;
}
You can also access the mutator function via $addComment.mutate—the function is the same.
Store states
Fetcher store
| State | loading | data | error | promise | When |
|-------|-----------|--------|---------|-----------|------|
| Initial (no subscribers) | false | — | — | — | Before any component subscribes |
| Loading (no cache) | true | — | — | Promise | First fetch, or after invalidate |
| Loading (stale cache) | true | Previous data | — | Promise | Refetch when cacheLifetime cache exists |
| Success | false | T | — | — | Fetcher resolved |
| Error | false | Previous data or — | E | — | Fetcher rejected |
| Conditional fetch disabled | false | — | — | — | Any key part is null/undefined/false |
| Deduplicated (from cache) | false | T | — | — | Same key fetched within dedupeTime |
Key behaviors:
invalidatewipes the cache entirely—the store goes back to "Loading (no cache)" with a spinner.revalidatemarks the cache as stale—the store shows "Loading (stale cache)" with the previous data visible.- When the store loses all subscribers (
onStop), it resets to the initial state.
Mutator store
| State | loading | data | error | When |
|-------|-----------|--------|---------|------|
| Initial | false | — | — | Before mutate() is called |
| Loading | true | — | — | mutate() called, awaiting result |
| Success | false | Result | — | Mutation resolved |
| Error | false | — | E | Mutation rejected |
Key behaviors:
mutate()always resetsdataanderrorbefore starting.- When
throttleCallsistrue(default), callingmutate()while already loading is a no-op. - When the store loses all subscribers, it resets to the initial state. Any in-flight mutation results are discarded.
Third returned item
(we didn't come up with a name for it 😅)
nanoquery function returns a third item that gives you a bit more manual control over the behavior of the cache.
// store/fetcher.ts
import { nanoquery } from '@nanostores/query';
export const [,, { invalidateKeys, revalidateKeys, mutateCache }] = nanoquery();
Both invalidateKeys and revalidateKeys accept one argument—the keys—in 3 different forms, that we call key selector. More on them in section How cache works
// Single key
invalidateKeys("/api/whoAmI");
// Array of keys
invalidateKeys(["/api/dashboard", "/api/projects"]);
/**
* A function that will be called against all keys in cach
