micro-graphql-react-custom-fetch

A light (2.8K min+gzip) and simple solution for painlessly connecting your React components to a GraphQL endpoint.

Usage no npm install needed!

<script type="module">
  import microGraphqlReactCustomFetch from 'https://cdn.skypack.dev/micro-graphql-react-custom-fetch';
</script>

README

npm version Build Status codecov code style: prettier

micro-graphql-react

A light (2.8K min+gzip) and simple solution for painlessly connecting your React components to a GraphQL endpoint.

Queries are fetched via HTTP GET, so while the client-side caching is in some ways not as robust as Apollo's, you can set up a Service Worker to cache results there; Google's Workbox, or sw-toolbox make this easy.

Live Demo

To see a live demo of this library managing GraphQL requests, check out this Code Sandbox

A note on cache invalidation

This library will not add metadata to your queries, and attempt to automatically update your cached entries from mutation results. The reason, quite simply, is because this is a hard problem, and no existing library handles it completely. Rather than try to solve this, you're given some simple primitives which allow you to specify how given mutations should affect cached results. It's slightly more work, but it allows you to tailer your solution to your app's precise needs, and, given the predictable, standard nature of GraphQL results, composes well. This is all explained at length below.

For more information on the difficulties of GraphQL caching, see this explanation

Installation

npm i micro-graphql-react --save

Note - this project ships standard, modern JavaScript (ES6, object spread, etc) that works in all evergreen browsers. If you need to support ES5 environments like IE11, just add an alias pointing to the ES5 build in your webpack config like so

alias: {
  "micro-graphql-react": "node_modules/micro-graphql-react/index-es5.js"
},

(alias goes under the resolve section in webpack.config.js)

Creating a client

Before you do anything, you'll need to create a client.

import { Client, setDefaultClient } from "micro-graphql-react";

const client = new Client({
  endpoint: "/graphql",
  fetchOptions: { credentials: "include" }
});

setDefaultClient(client);

Now that client will be used by default, everywhere, unless you manually pass in a different client to a component's options, as discussed below.

Client options

Option Description
endpoint URL for your GraphQL endpoint
fetchOptions Options to send along with all fetches
cacheSize Default cache size to use for all caches created by this client, as needed, for all queries it processes
noCaching If true, this will turn off caching altogether for all queries it processes

Client api

Option Description
runQuery(query: String, variables?: Object) Manually run this GraphQL query
runMutation(mutation: String, variables?: Object) Manually run this GraphQL mutation
forceUpdate(query) Manually update any components rendering that query. This is useful if you (dangerously) update a query's cache, as discussed in the caching section, below

Running queries and mutations

Hooks

This project exports a useQuery and useMutation hook.

import { useQuery, useMutation, buildQuery, buildMutation } from "micro-graphql-react";

const ComponentWithQueryAndMutation = props => {
  let { loading, loaded, data, currentQuery } = useQuery(buildQuery(basicQuery, { query: props.query }, options));
  let { running, finished, runMutation } = useMutation(buildMutation("someMutation{}"));

  return <DoStuff {...props} {...{ loading, loaded, currentQuery, data, running, runMutation }} />;
};

Render props

There's also a render props-based GraphQL component that supports both mutations and queries.

import { GraphQL, buildQuery, buildMutation } from "micro-graphql-react";

<GraphQL
  query={{
    loadBooks: buildQuery(LOAD_BOOKS, { title: this.state.titleSearch }, { onMutation: hardResetStrategy("Book") })
  }}
  mutation={{ updateBook: buildMutation(UPDATE_BOOK) }}
>
  {({ loadBooks: { loading, loaded, data, error }, updateBook: { runMutation } }) => (
    <div>
      {loading ? <span>Loading...</span> : null}
      {loaded && data && data.allBooks ? <DisplayBooks books={data.allBooks.Books} editBook={this.editBook} /> : null}
      <br />
      {this.state.editingBook ? <UpdateBook book={this.state.editingBook} updateBook={runMutation} /> : null}
    </div>
  )}
</GraphQL>;

Building queries

Construct each query with the buildQuery method. The first argument is the query text itself. The second, optional argument, is the query's variables. You can also pass a third options argument, which can contain any of the following properties:

Option Description
onMutation A map of mutations, along with handlers. This is how you update your cached results after mutations, and is explained more fully below
client Manually pass in a client to be used for this query, which will override the default client
cache Manually pass in a cache object to be used for this query
active If passed, and if false, disables any further query loading. If not specified, the hook will update automatically, as expected

Be sure to use the compress tag to remove un-needed whitespace from your query text, since it will be sent via HTTP GET—for more information, see here.

An even better option would be to use my persisted queries helper. This not only removes the entire query text from your nextwork requests altogether, but also from your bundled code.

Props passed for each query

For each query you specify, an object will be returned from the hook, or for render props, passed in the callback's props by that same name, with the following properties.

Props Description
loading Fetch is executing for your query
loaded Fetch has finished executing for your query
data If the last fetch finished successfully, this will contain the data returned, else null
currentQuery The query that was run, which produced the current results. This updates synchronously with updates to data, so you can use changes here as an easy way to subscribe to query result changes. This will not have a value until there are results passed to data. In other words, changes to loading do not affect this value
error If the last fetch did not finish successfully, this will contain the errors that were returned, else null
reload A function you can call to manually re-fetch the current query
clearCache Clear the cache for this component
clearCacheAndReload Calls clearCache, followed by reload

Building mutations

Construct each mutation with the buildMutation method. The first argument is the mutation text. The second, optional options argument can accept only a client property, which will override the client default, same as with queries.

Props passed for each mutation

For each mutation you specify, an object will be passed in the component's props by that same name, with the following properties.

Props Description
running Mutation is executing
finished Mutation has finished executing
runMutation A function you can call when you want to run your mutation. Pass it an object with your variables

Caching

The client object maintains a cache of each query it comes across when processing your components. The cache is LRU with a default size of 10 and, again, stored at the level of each specific query, not the GraphQL type. As your instances mount and unmount, and update, the cache will be checked for existing results to matching queries.

Cache object

You can import the Cache class like this

import { Cache } from "micro-graphql-react";

When instantiating a new cache object, you can optionally pass in a cache size.

let cache = new Cache(15);

To turn caching off for a given query, just create a cache with size 0, and pass that in for the query. Or as noted above, you can create a client with the noCaching option set to true, to turn caching off for all queries processed by that client.

Cache api

The cache object has the following properties and methods

Member Description
get entries() An array of the current entries. Each entry is an array of length 2, of the form [key, value]. The cache entry key is the actual GraphQL url query that was run. If you'd like to inspect it, see the variables that were sent, etc, just use your favorite url parsing utility, like url-parse. And of course the cache value itself is whatever the server sent back for that query. If the query is still pending, then the entry will be a promise for that request.
get(key) Gets the cache entry for a particular key
set(key, value) Sets the cache entry for a particular key
delete(key) Deletes the cache entry for a particular key
clearCache() Clears all entries from the cache

Cache invalidation

The onMutation option that query options take is an object, or array of objects, of the form { when: string|regularExpression, run: function }

when is a string or regular expression that's tested against each result of any mutations that finish. If the mutation has any matches, then run will be called with three arguments: an object with these propertes, described below, { softReset, currentResults, hardReset, cache, refresh }; the entire mutation result; and the mutation's variables object.

Arg Description
softReset Clears the cache, but does not re-issue any queries. It can optionally take an argument of new, updated results, which will replace the current data props
currentResults The current results that are passed as your data prop
hardReset Clears the cache, and re-load the current query from the network
cache The actual cache object. You can enumerate its entries, and update whatever you need.
refresh Refreshes the current query, from cache if present. You'll likely want to call this after modifying the cache.

Many use cases follow. They're based on an hypothetical book tracking website since, if we're honest, the Todo example has been stretched to its limit—and also I built a book tracking website and so already have some data to work with :D

The code below was tested on an actual GraphQL endpoint created by my mongo-graphql-starter project

All examples use the query decorator, but the format is identical to the GraphQL component.

Use Case 1: Hard reset and reload after any mutation

Let's say that whenever a mutation happens, we want to immediately invalidate any related queries' caches, and reload the current queries from the network. We understand that this may cause a book that we just edited to immediately disappear from our current search results, since it no longer matches our search criteria, but that's what we want.

The hard reload method that's passed makes this easy. Let's see how to use this in a (contrived) component that queries, and displays some books.

export const BookQueryComponent = props => (
  <div>
    <GraphQL
      query={{
        books: buildQuery(
          BOOKS_QUERY,
          { page: props.page },
          { onMutation: { when: /(update|create|delete)Books?/, run: ({ hardReset }) => hardReset() } }
        )
      }}
    >
      {({ books: { data } }) =>
        data ? (
          <ul>
            {data.allBooks.Books.map(b => (
              <li key={b._id}>
                {b.title} - {b.pages}
              </li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

Here we specify a regex matching every kind of book mutation we have, and upon completion, we just clear the cache, and reload by calling hardReset(). It's hard not to be at least a littler dissatisfied with this solution; the boilerplate is non-trivial. Let's take a look at a similar (again contrived) component, but for the subjects we can apply to books

export const SubjectQueryComponent = props => (
  <div>
    <GraphQL
      query={{
        subjects: buildQuery(
          SUBJECTS_QUERY,
          { page: props.page },
          { onMutation: { when: /(update|create|delete)Subjects?/, run: ({ hardReset }) => hardReset() } }
        )
      }}
    >
      {({ subjects: { data } }) =>
        data ? (
          <ul>
            {data.allSubjects.Subjects.map(s => (
              <li key={s._id}>{s.name}</li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

Assuming our GraphQL operations have a consistent naming structure—and they should—then some pretty obvious patterns emerge. We can auto-generate this structure just from the name of our type, like so

const hardResetStrategy = name => ({
  when: new RegExp(`(update|create|delete)${name}s?`),
  run: ({ hardReset }) => hardReset()
});

and then apply it like so

export const BookQueryComponent = props => (
  <div>
    <GraphQL query={{ books: buildQuery(BOOKS_QUERY, { page: props.page }, { onMutation: hardResetStrategy("Book") }) }}>
      {({ books: { data } }) =>
        data ? (
          <ul>
            {data.allBooks.Books.map(b => (
              <li key={b._id}>
                {b.title} - {b.pages}
              </li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

export const SubjectQueryComponent = props => (
  <div>
    <GraphQL query={{ subjects: buildQuery(SUBJECTS_QUERY, { page: props.page }, { onMutation: hardResetStrategy("Subject") }) }}>
      {({ subjects: { data } }) =>
        data ? (
          <ul>
            {data.allSubjects.Subjects.map(s => (
              <li key={s._id}>{s.name}</li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

Use Case 2: Update current results, but otherwise clear the cache

Let's say that, upon successful mutation, you want to update your current results based on what was changed, clear all other cache entries, including the existing one, but not run any network requests. So if you're currently searching for an author of "Dumas Malone," but one of the current results was clearly written by Shelby Foote, and you click the book's edit button and fix it, you want that book to now show the updated values, but stay in the current results, since re-loading the current query and having the book just vanish is bad UX in your opinion.

Here's the same books component as above, but with our new cache strategy

export const BookQueryComponent = props => (
  <div>
    <GraphQL
      query={{
        books: buildQuery(
          BOOKS_QUERY,
          { page: props.page },
          {
            onMutation: {
              when: "updateBook",
              run: ({ softReset, currentResults }, { updateBook: { Book } }) => {
                let CachedBook = currentResults.allBooks.Books.find(b => b._id == Book._id);
                CachedBook && Object.assign(CachedBook, Book);
                softReset(currentResults);
              }
            }
          }
        )
      }}
    >
      {({ books: { data } }) =>
        data ? (
          <ul>
            {data.allBooks.Books.map(b => (
              <li key={b._id}>
                {b.title} - {b.pages}
              </li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

Whenever a mutation comes back with updateBook results, we use softReset to update our current results, while clearing our cache, including the current cache result; so if you page up, then come back down to where you were, a new network request will be run, and your edited book will no longer be there, as expected. Note that in this example we're actually mutating our current cache result; that's fine.

This seems like a lot of boilerplate, but again, lets look at the subjects component and see if any patterns emerge.

export const SubjectQueryComponent = props => (
  <div>
    <GraphQL
      query={{
        subjects: buildQuery(
          SUBJECTS_QUERY,
          { page: props.page },
          {
            onMutation: {
              when: "updateSubject",
              run: ({ softReset, currentResults }, { updateSubject: { Subject } }) => {
                let CachedSubject = currentResults.allSubjects.Subjects.find(s => s._id == Subject._id);
                CachedSubject && Object.assign(CachedSubject, Subject);
                softReset(currentResults);
              }
            }
          }
        )
      }}
    >
      {({ subjects: { data } }) =>
        data ? (
          <ul>
            {data.allSubjects.Subjects.map(s => (
              <li key={s._id}>{s.name}</li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

As before, since we've named our GraphQL operations consistently, there's some pretty obvious repetition. Let's again refactor this into a helper method that can be re-used throughout our app.

const standardUpdateSingleStrategy = name => ({
  when: `update${name}`,
  run: ({ softReset, currentResults }, { [`update${name}`]: { [name]: updatedItem } }) => {
    let CachedItem = currentResults[`all${name}s`][`${name}s`].find(x => x._id == updatedItem._id);
    CachedItem && Object.assign(CachedItem, updatedItem);
    softReset(currentResults);
  }
});

Now we can clean up all that boilerplate from before

export const BookQueryComponent = props => (
  <div>
    <GraphQL query={{ books: buildQuery(BOOKS_QUERY, { page: props.page }, { onMutation: standardUpdateSingleStrategy("Book") }) }}>
      {({ books: { data } }) =>
        data ? (
          <ul>
            {data.allBooks.Books.map(b => (
              <li key={b._id}>
                {b.title} - {b.pages}
              </li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

export const SubjectQueryComponent = props => (
  <div>
    <GraphQL query={{ subjects: buildQuery(SUBJECTS_QUERY, { page: props.page }, { onMutation: standardUpdateSingleStrategy("Subject") }) }}>
      {({ subjects: { data } }) =>
        data ? (
          <ul>
            {data.allSubjects.Subjects.map(s => (
              <li key={s._id}>{s.name}</li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

And if you have multiple mutations, just pass them in an array

Use Case 3: Manually update all affected cache entries

Let's say you want to intercept mutation results, and manually update your cache. This is difficult to get right, so be careful.

There's a cache object passed to the run callback, with an entries property you can iterate, and update. As before, it's fine to just mutate the cached entries directly; just don't forget to call the refresh method when done, so your current results will update.

This example shows how you can remove a deleted book from every cache result.

export const BookQueryComponent = props => (
  <div>
    <GraphQL
      query={{
        books: buildQuery(
          BOOKS_QUERY,
          { page: props.page },
          {
            onMutation: {
              when: "deleteBook",
              run: ({ cache, refresh }, mutationResponse, args) => {
                cache.entries.forEach(([key, results]) => {
                  results.data.allBooks.Books = results.data.allBooks.Books.filter(b => b._id != args._id);
                });
                refresh();
              }
            }
          }
        )
      }}
    >
      {({ books: { data } }) =>
        data ? (
          <ul>
            {data.allBooks.Books.map(b => (
              <li key={b._id}>
                {b.title} - {b.pages}
              </li>
            ))}
          </ul>
        ) : null
      }
    </GraphQL>
  </div>
);

It's worth noting that this solution will have problems if your results are paged. Any non-active entries should really be purged and re-loaded when next needed, so a full, correct page of results will come back.

Use Case 4: Globally modify the cache as needed

Prior use cases have all relied on a component or hook to do the cache syncing or updating; however, you can also subscribe to mutation results directly on the relevant client instance, and clear or update cache entries as needed. For example

graphqlClient.subscribeMutation({ when: /createBook/, run: () => clearCache(GetBooksQuery) });

//elsewhere
export const clearCache = (...cacheNames) => {
  cacheNames.forEach(name => {
    let cache = graphqlClient.getCache(name);
    cache && cache.clearCache();
  });
};

This code will clear all book search results whenever a new book is created, no matter if books are currently rendered anywhere by a hook. This ensures that if you create a book in a "create book" screen, and then browse back to a books query, no cached results will show, and instead a new query will run, so the new book will have a chance to show up in the new results (if it matches the search criteria).

Of course you can also subscribe to updates, and manually update your cache, subject to the same warnings as above. Be sure to call graphqlClient.forceUpdate(queryName) to broadcast your updates to any components rendering them.

A note on cache management code

There's always a risk with "micro" libraries resulting in more application code overall, since they do too little. Remember, this library passes on doing client-side cache updating not so that it can artificially shrink it's bundle size, but rather because this is a problem that's all but impossible to do in an automated way. Again, this is explained here.

If you see a lot of repetative boilerplate being created in your app code to update caches, take a step back and make sure you're abstracting and generalizing appropriately. Make sure your GraphQL schema is as consistent as possible, so the work to keep the cache in sync will similarly be consistent and predictable, and therefore able to be reused.

Manually running queries or mutations

It's entirely possible some pieces of data may need to be loaded from, and stored in your state manager, rather than fetched via a component's lifecycle; this is easily accomodated. The GraphQL component, and component decorators run their queries and mutations through the client object you're already setting via setDefaultClient. You can call those methods yourself, in your state manager (or anywhere).

Client api

  • runQuery(query: String, variables?: Object)
  • runMutation(mutation: String, variables?: Object)

For example, to imperatively run the query from above in application code, you can do

client.runQuery(
  compress`query ALL_BOOKS ($page: Int) {
    allBooks(PAGE: $page, PAGE_SIZE: 3) {
      Books { _id title }
    }
  }`,
  { title: 1 }
);

and to run the mutation from above, you can do

client.runMutation(
  `mutation modifyBook($title: String) {
    updateBook(_id: "591a83af2361e40c542f12ab", Updates: { title: $title }) {
      Book { _id title }
    }
  }`,
  { title: "New title" }
);

Use in old browsers

By default this library ships modern, standard JavaScript, which should work in all decent browsers. If you have to support older browsers like IE, then just add the following alias to your webpack's resolve section

  resolve: {
    alias: {
      "micro-graphql-react": "node_modules/micro-graphql-react/index-es5.js"
    },
    modules: [path.resolve("./"), path.resolve("./node_modules")]
  }