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:
__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.
Handle of the library, imported like so:
import G from 'gitstate'
For configuring GitState client.
G.init({ origin: "<server ip>" })
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) {idtitlecontents}}`;
// 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')()
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 })
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// }// }
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.
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'// }
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 resource
G.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.
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)
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){idemail}}`;
// 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
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 })
// 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);
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},// }
exec({merge: true,trigger: 'userDetails',callback: res => /* do something */})
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!