A tiny data fetcher for Nano Stores.
- Small. 1.65 Kb (minified and gzipped).
- Familiar DX. If you've used
swr
orreact-query
, you'll get the same treatment, but for 10-20% of the size. - Built-in cache.
stale-while-revalidate
caching from HTTP RFC 5861. User rarely sees unnecessary loaders or stale data. - Revalidate cache. Automaticallty 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.
npm install nanostores @nanostores/query
See Nano Stores docs about using the store and subscribing to store’s changes in UI frameworks.
First, we define the context. It allows us to share the default fetcher implementation between all fetcher stores, refetching settings, and allows for simple mocking in tests and stories.
// store/fetcher.ts
import { nanoquery } from '@nanostores/query';
export const [createFetcherStore, createMutatorStore] = nanoquery({
fetcher: (...keys: string[]) => 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.
// components/Post.tsx
const Post = () => {
const { data, loading } = useStore($currentPost);
if (loading) return <>Loading...</>;
if (!data) return <>Error!</>;
return <div>{data.content}</div>;
};
export const $currentPost = createFetcherStore<Post>(['/api/post/', $currentPostId]);
It accepts two arguments: key and fetcher options.
type KeyParts = undefined | Array<ReadableAtom<string | null | undefined> | string>
Under the hood, nanoquery will get the string values and pass them to your fetcher
like this: fetcher(...keyPartsAsStrings)
. If any atom value is either null
or
undefined
, we never call the fetcher—this is the conditional fetching technique we
have.
type Options = {
// The async function that actually returns the data
fetcher?: (...keyParts: string[]) => Promise<unknown>;
// How much time should pass between running fetcher for the exact same key parts
// default = 4s
dedupeTime?: number;
// If we should revalidate the data when the window focuses
// default = false
refetchOnFocus?: boolean;
// If we should revalidate the data when network connection restores
// default = false
refetchOnReconnect?: boolean;
// If we should run revalidation on an interval, in ms
// default = 0, no interval
refetchInterval?: number;
}
The same options can be set on the context level where you actually get the
createFetcherStore
.
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:
data
is the data you pass to themutate
function;invalidate
allows you to mark other keys as stale so they are refetched next time;getCacheUpdater
allows you to get current cache value by key and update it with a new value. The key is also invalidated by default.
export const $addComment = createMutatorStore<Comment>(
async ({ data: comment, invalidate, getCacheUpdater }) => {
// You can either invalidate the author…
invalidate(`/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 `invalidate`, we will only
// invalidate 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>
);
};
All examples above use module-scoped stores, therefore they can only have a single data point stored. But what if you need, say, a store that fetches data based on component state? Nano Stores do not limit you in any way, you can easily achieve this by creating a store instance limited to a single component:
const createStore = (id: string) => () =>
createFetcherStore<{ avatarUrl: string }>(`/api/user/${id}`);
const UserAvatar: FC<{ id: string }> = ({ id }) => {
const [$user] = useState(createStore(id));
const { data } = useStore($user);
if (!data) return null;
return <img src={data.avatarUrl} />;
};
This way you can leverage all nanoquery features, like cache or refetching, but not give up the flexibility of component-level data fetching.