Socket
Socket
Sign inDemoInstall

cosmonaut

Package Overview
Dependencies
16
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Install Socket

Detect and block malicious and high-risk dependencies

Install

    cosmonaut

Scalable data fetching for React


Version published
Weekly downloads
11
increased by1000%
Maintainers
1
Install size
1.64 MB
Created
Weekly downloads
 

Readme

Source

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.

FAQs

Last updated on 05 Sep 2021

Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc