cosmonaut
v0.4.0
Published
Scalable data fetching for React
Downloads
6
Readme
Cosmonaut – scalable data fetching for React
Cosmonaut is a highly performant, flexible, and typesafe data fetching and caching library. Designed for the demanding React app.
It features:
- Powerful hooks for even the most complex, dynamic data fetching.
- Advanced data-sharing to keep data in sync and maximize cache hits.
- A TypeScript-first API design.
- ... and much more ...
Quick start
Install:
npm install --save cosmonaut
Basic usage:
import { model, useModel } from "cosmonaut";
const ArticleModel = defineModel(
(id: string) => {
return getJson<Article>(`/api/article/${id}`);
},
{
invalidAge: "1h",
refreshAge: "1m",
transform: {
onRead(article) {
return {
...article,
created: parseDate(article.created),
};
},
onWrite(article) {
return {
...article,
created: article.created.toISOString(),
};
},
},
}
);
const ArticleModel = transformModel({
model: defineModel(
(id: string) => {
return getJson<Article>(`/api/article/${id}`);
},
{
invalidAge: "1h",
refreshAge: "1m",
}
),
onRead(article) {
return {
...article,
created: parseDate(article.created),
};
},
onWrite(article) {
return {
...article,
created: article.created.toISOString(),
};
},
});
const ArticlesModel = defineModel(
() => {
return getJson<Article[]>(`/api/article/`);
},
{
normalize: [(article) => ArticleModel(article.id)],
invalidAge: "1h",
refreshAge: "1m",
transform: (articles) => {
return {
items: articles,
count: articles.length,
};
},
}
);
const FullArticleModel = deriveModel((id: string) => {
return {
article: get(ArticleModel(id)),
comments: get(CommentsModel(id)),
};
});
function ArticleView({ id }: { id: string }) {
// Implicit selectModel
const { article, comments } = useModel(() => {
return {
article: get(ArticleModel(id)),
comments: get(CommentsModel(id)),
};
});
}
Hooks
useModel
is designed to work with suspense. A call to useModel
will wait for
data to resolve before continuing. This means that sequential useModel
calls
will also result in sequential data loading, but it's also trivial to make
parallel requests instead:
// Fetch the current user and a list of articles in parallel
const [user, articles] = useModel([User(), Articles()]);
// Alternatively, the above can also be written as:
const { user, articles } = useModel({
user: User(),
articles: Articles(),
});
This enables us to work around one of the more annoying limitations of
the Rules of Hooks: the inability to call hooks inside a loop. With useModel
,
we can make an arbitrary number of requests:
function ArticleListView({ articleIds }: { articleIds: string[] }) {
const articles = useModel(articleIds.map((id) => Article({ id })));
}
Conditional fetching is also trivial – simply pass null
when a request is
not needed. useModel
will return undefined
in that scenario.
function ConditionalView({ shouldShowUser }: { shouldShowUser: boolean }) {
const user = useModel(shouldShowUser ? User() : null);
return <div>{user?.name}</div>;
}
Do you have even more complex or dynamic requirements? Would a simple if
condition make your code much more readable? Cosmonaut can enable that too!
See the section on Select models
.
If suspense is not desired, pass { async: true }
for an alternate API:
const { data, loading, error } = useModel(User(), { async: true });
Data-sharing
Cosmonaut can join data from separate data sources to ensure that the data you display is always in sync with each other.
A classic example is a pair of list/list-item endpoints:
- An "item" endpoint returns an individual article for a given ID.
- A "list" endpoint returns an array of items, each of which contains the same data as if they were retrieved individually from the "item" endpoint.
We want to ensure that the same data is displayed for a given item, regardless of whether it was returned via the "item" or the "list" endpoint.
We can describe this relationship by providing a schema, for example:
import { model, useModel } from "cosmonaut";
const Article = model<ArticleData, { id: string }>({
get: ({ id }) => fetch(`/api/article/${id}`).then((r) => r.json()),
});
const LatestArticles = model<ArticleData[]>({
get: () => fetch("/api/latest-articles/").then((r) => r.json()),
schema: [
// Declare that each item in the response is an Article that can
// be retrieved using the `id` param.
(data) => Article({ id: data.id }),
],
});
Under the hood, Cosmonaut breaks apart all data for the LatestArticles
model
and stores them as individual Article
models to ensure that there's only
ever a single source of truth for a given Article. This has two effects:
- If another component requires the use of a specific
Article
, and we've already retrieved it as part ofLatestArticles
, Cosmonaut will return it immediately without making another network request. - If that
Article
is updated from anywhere, the update will be reflected in all places it is used, including inLatestArticles
.
Data-sharing is not limited to list/list-item relationships. See the following examples for other common usage patterns:
const Article = model<ArticleData, { id: string }>({
get: /* ... */,
schema: {
meta: {
// Shared User
author: (user) => User({ id: user.id }),
// Array of shared Users
collaborators: [(user) => User({ id: user.id })],
},
},
});
// A user can be retrieved via User() or User({ id: '...' }).
// The former implies the current user.
const User = model<UserData, { id: string } | undefined>({
get: /* ... */,
// This schema allows the data for the User() query to be shared
// with queries for a User with the same id.
schema: (user, query) => query.model({ id: user.id })
});
Select models
The standard Cosmonaut model is a "fetch" model. These retrieve data from an external source and return a Promise.
In contrast, "select" models derive data from one or more other models.
They let us encapsulate complex useModel
usages, and create another
model out of it.
For example, let's say we had a complex computation being done inside a component:
function ComplexView({ userId }: { userId: string }) {
const user = useModel(User({ id: userId }));
const recentArticlesForUser = useModel(
user.recentArticleIds.map((articleId) => Article({ id: articleId }))
);
const totalWordCount = recentArticlesForUser.reduce(
(sum, article) => sum + article.wordCount,
0
);
const averageWordCount = totalWordCount / recentArticlesForUser.length;
}
If we wanted to share this computation with other components, one option is to turn it into a custom hook. For a similar amount of effort, we can turn it into a select model instead:
import { select, useModel } from "cosmonaut";
const AverageWordCountForAuthor = select<number, { userId: string }>({
// Note that select models get a special `useModel` as their 2nd argument
get: ({ userId }, useModel) => {
const user = useModel(User({ id: userId }));
const recentArticlesForUser = useModel(
user.recentArticleIds.map((articleId) => Article({ id: articleId }))
);
const totalWordCount = recentArticlesForUser.reduce(
(sum, article) => sum + article.wordCount,
0
);
return totalWordCount / recentArticlesForUser.length;
},
});
function ComplexView({ userId }: { userId: string }) {
const averageWordCount = useModel(AverageWordCountForAuthor({ userId }));
}
This is better than using a custom hook for the following reasons:
Hooks can only be used inside component bodies.
In contrast, we can use models anywhere with Cosmonaut's imperative API. A common usecase for this is within an event handler:
import { useCosmonaut } from "cosmonaut";
function ComplexView({ userId }: { userId: string }) {
const client = useCosmonaut();
return (
<form
onSubmit={async () => {
const averageWordCount = await client.get(
AverageWordCountForAuthor({ userId })
);
if (currentWordCount < averageWordCount) {
setErrorMessage("You must work harder.");
} else {
submitArticle();
}
}}
/>
);
}
The Rules of Hooks don't apply!
Go ahead and call
useModel
inside anif
condition or nested function. One caveat is that while you can calluseModel
inside a loop, this will be sequential by default! Pass the{ async: true }
option to work around this.The computation is memoized for you, so you don't have to worry about
useMemo
.Limited scope of concern. This can be seen as a pro or a con. A custom hook has access to
useState
,useEffect
, etc... A select model is limited touseModel
. This makes its behavior more predictable, but can be limiting if you'd like to mix fetched data with React state or context.