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 🙏

© 2024 – Pkg Stats / Ryan Hefner

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:

  1. 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.
  2. 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: {
      // 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 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.