GitState — Git-style Isomorphic State Management with GraphQL

Not a day goes by without a new idea for a state management library popping up. I want to talk about yet another one. But be warned, this is not a real library. It is an idea I had and wanted to share with the community.

This is supposed to be a mashup of Git and GraphQL logos

I will call this hypothetical library GitState.

GitState helps you can manage your state locally and keep parts of it in sync with your remote datastore. All these transactions are handled with a git-flavoured api. It should work with any GraphQL backend with possibly some configuration. It is responsible for the following:

  • Keeping / managing local state — It acts like a predictable, immutable state container for both local and tracked remote data.
  • Handling data flow— You can track slices of your remote datastore, and expand / alter these slices (for load more, filtering etc.). Loading and error states are handled by GitState.
  • Handling the request flow — Requests are completely abstracted away. You can add or modify tracks, nothing hits the backend until you execute G.pull. Once you do, GitState generates an optimal query based on the tracks and the cache, so it hits the backend as few times as possible, requesting minimal amount of payload.
  • Caching — GitState keeps track of fetched resources by their __typename and id’s separately from the state, normalised and independent from the hierarchy they were pulled in. It allows caching strategies to be defined globally, for each type or for each field of each type individually. These are considered while optimising the queries.

Here is a brief walkthrough to the API. Note that all functions are auto-curried, in a config first data last order.

G

Handle of the library, imported like so:

import G from 'gitstate'

G.init

For configuring GitState client.

G.init({ origin: "<server ip>" })

G.track

Similar to git track …, this method tracks a slice of the remote datastore. Takes in a track key and a GQL query. It can optionally take query parameters and options. It either creates a new track, or overrides if it already exists.

G.track(key)(query)(params)(options)

The scope of an existing track could easily be modified to support things like pagination, querying and filtering. This can easily be achieved by overriding the query parameters. To do this, simply omit the query and provide the new values.

G.track(key)(params)(options)

Example:

const q = `  query Posts($limit: Int, $offset: Int){    posts(limit: $limit, offset: $offset) {      id      title      contents    }  }`;
// Create a new track & start pullingG.track('posts')(q)({ limit: 10, offset: 0 })()G.pull('posts')()
// ...Later// Modify the scope of the tracked postsG.track('posts')({ offset: 10 })({ append: true })G.pull('posts')()

G.pull

Takes in two optional parameters, track key and options. Omitting either one works. When track key is provided, it only attempts to fetch the given track, otherwise it targets all existing the tracks.

G.pull(key)(options)

When called, GitState fetches only the data that needs to be fetched. If a track has changes in their query parameters, it gets fetched. If not, the decision is based on the cache resolution strategy or the force option.

Example:

G.pull()()G.pull('posts')()G.pull()({ force: true })G.pull('posts')({ force: true })

G.status

Status is an object where GitState keeps request info and track metadata. It is organised by the track keys. This object could be used to handle loading / error states, and access track parameters.

Example:

// G.status// {//  userDetails: {//   fetched: <boolean>, // has it been fetched//   fetching: <boolean>, // is it fetching now//   loading: <boolean>, // see G.exec//   hasChange: <boolean>, // did the request params change//   error: <err object>, // error object for the last request//   params: {...}, // current query parameters//   options: {...}, // current query options//  }// }

G.forms

Forms is an internally used object that holds a normalised and flattened index of all fetched resources by their __typename and id. GitState keeps this object in sync with the backend after each pull. It holds resource data and the last fetch time (for each individual field). Forms object is the single source of truth for the most recently fetched versions of resources, and the application state is a derivation of this object.

G.state

Local state of the application. Organised by track keys. The getter function derives the current state from the forms object, thus it always contains the latest fetched version of each resource. Additionally, it holds the non-tracked local variables on the root level. To get the state, simply call the getter function.

Example:

G.track('userDetails')(`...`)G.track('favoritePosts')(`...`)G.pull()
// G.state()// {//  userDetails: {...},//  favoritePosts: {...},//  nonTrackedLocalVariable: 'blah'// }

G.add

Creates or updates a node in the local state tree. It takes an object path, and a new value for the node. It is auto-vivified, meaning that it creates a new node and all its path if it doesn’t exist.

G.add(path)(val)

If the updated node is tracked, that means it is actually just a derivation of a resource on the forms object. In that case, GitState updates the associated resource form, so all state variables derived from that resource also gets updated.

Example:

G.add('nonTrackedLocalVariable')('blah')G.add('counter')(G.state().counter + 1)G.commit()
// Below, it actually updates the associated `User` resource on G.forms, so this change gets reflected on all references on the local state object that points to that resourceG.add('userDetails.email')('[email protected]')G.commit()

Note that this change is only local. The GraphQL way of modifying data is by calling mutations. Updates to the local state on tracked data gets overridden on the next pull if the change didn’t also occur in the backend. See G.exec.

G.commit

The add operations are just queued and they aren’t effective until they are committed. This assures that the developer can decide exactly when to update the state, and until then, everything that uses the state receives the same version.

G.commit(options)

G.exec

This command is used for executing mutations. It takes in an execution key and a GQL mutation. It can additionally take parameters and some options.

G.exec(key)(mutation)(params)(options)

The loading / error state of a mutation call is handled on G.status just like the tracks. It is organised under the execution key:

G.exec('modifyUserDetails')(`...`)({...})({...})
// G.status// {//  modifyUserDetails: {//   loading: <boolean>,//   error: <err object>,//  }// }

Mutations are the only way to perform write operations in GQL, and the updates must be kept in sync with the local state. There are a few options to do this. Here is an example mutation to show how these options work:

const m = `  mutation ModifyUserEmail($id: String!){    modifyUserEmail(id: $id){      id      email    }  }`;
// Note that since functions are curried, `exec` below is assigned to a function that takes in the options to trigger the G.exec call.
const exec = G.exec('modifyUserEmail')(m)({ id: '123' });
  • Merge — If you know that the mutation responds with the modified resource, or just the modified bits, you can use the merge option. This goes through the response, matches the resources to the forms counterparts by their given __typename and id’s, and updates the forms object. Thus, you’ll have the latest version of the modified resources on your local state.
exec({ merge: true })
  • Manual — If you know what field changed without consulting the server response, or if you wish to update the state manually, you can use your preferred method for asynchronousy, and update the local state like so:
// Manually update the local stateconst updateEmail = res => {  G.add('userDetails.email')(res.email);  G.commit()}
// Callbackexec({ callback: updateEmail })
// Promiseexec({ promise: true }).then(updateEmail)
// Async / awaitconst res = await exec({ async: true })updateEmail(res);
  • Trigger — There might be times where you don’t know how the resource changed, and the backend might not be returning the modified version. Or for some reason you might need to refetch a track. In those cases, you can use the trigger option. It takes in a track key or multiple track keys, and associates the mutation with the given tracks. It sets the loading flag of all associated tracks on G.status to true, and once resolved, it triggers a forced G.pull on them.
exec({ trigger: 'userDetails' })
// G.status// {//  userDetails: {..., loading: true },//  modifyUserEmail: {..., loading: true},// }
  • Hybrid — You can combine all of the above:
exec({  merge: true,  trigger: 'userDetails',  callback: res => /* do something */})

Final words

As I mentioned, this is not a real library. It is not complete, and probably there are many more things to consider to make this a reality. If you liked this idea, you might want to check out Apollo Client. It doesn’t have a git-styled api, but the way things are handled are quite similar.

I hope GitState inspires some, and I would love to hear what you think!

GitState — Git-style Isomorphic State Management with GraphQL was originally published in Hacker Noon on Medium, where people are continuing the conversation by highlighting and responding to this story.

Publication date: 
07/11/2018 - 22:20
Author: