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 }) {
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:
const [user, articles] = useModel([User(), Articles()]);
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: [
(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 of LatestArticles
, 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 in LatestArticles
.
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: {
author: (user) => User({ id: user.id }),
collaborators: [(user) => User({ id: user.id })],
},
},
});
const User = model<UserData, { id: string } | undefined>({
get: ,
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 }>({
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 an if
condition or nested function. One caveat is that while you can call useModel
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 to
useModel
. This makes its behavior more predictable, but can be limiting
if you'd like to mix fetched data with React state or context.