pui-cursor

immutable cursor implementation for react

Usage no npm install needed!

<script type="module">
  import puiCursor from 'https://cdn.skypack.dev/pui-cursor';
</script>

README

PUI Cursor

npm version Build Status Dependencies

Utility designed for immutable data in a React flux architecture.

Table of Contents

Cursors

PUI Cursors are simplified versions of Om Cursors designed for use with a React Flux architecture. It enables targeted, immutable updates to data; these updates are particularly useful for updating a store in React.

A cursor takes in data and a callback. The callback is used to propagate data into an app and create a new cursor with the updated data.

A minimal example of cursor setup is below:

const Cursor = require('pui-cursor');
const React = require('react');
const Zoo = require('./zoo');

class Application extends React.Component {
  constructor(props, context) {
    super(props, context);
    this.state.store = {animals: {lion: 'Larry', seal: 'Sebastian'}};
  }

  render() {
    const $store = new Cursor(this.state.store, updatedStore => this.setState({store: updatedStore}));

    return <Zoo animals={this.state.store.animals} $store={$store}/>;
  }
}

Our convention is to prefix Cursor instances with $, like $store in the above example. This convention differentiates the cursor from the data it contains.

For example in this setup, if the Zoo component calls this.props.$store.merge({visitors: ['Charles', 'Adam', 'Elena']});, the application store will now have visitors in addition to animals.

Timing

When the cursor is updated, the callback is called asynchronously (inside of a setImmediate() under the hood). This is to handle multiple synchronous updates to the cursor. The updates are batched together into a single callback.

Synchronous Mode

If you want to use synchronous callbacks, you can enable synchronous mode by setting

Cursor.async = false;

In synchronous mode, synchronous updates to the cursor are no longer batched. This can lead to many more callbacks and a reduction in performance. We recommend using synchronous mode only for unit tests.

Common Asynchronous Mistakes

Accessing the store before it updates

Using asynchronous callbacks can lead to unexpected behavior when accessing the store.

For example:

var store = [1,2];
const $store = new Cursor(store, callback);

If you update the cursor and try to access the store synchronously,

$store.push(3);
console.log($store.get());

you might expect the console to print [1,2,3]. Instead the console will print [1,2] because the callback has not fired yet.

You can use the React lifecycle methods such as componentWillReceiveProps or componentDidUpdate to work around this. For example, if you add the following function to a component that has the store as a prop,

componentWillReceiveProps(nextProps) {
  if (nextProps.store !== this.props.store) {
    console.log(nextProps.store);
  }
}

the console will print [1,2,3].

Stale Cursors

Another, more subtle, problem might arise from storing the cursor as a variable. If you are in a component with $store on props, you might want to write code like the following:

var $store = this.props.$store;
doSomethingAsync().then(function(something) {
  $store.push(something);
});

This code will work in isolation, but it has a race condition. If some other code updates the cursor (i.e. $store.push("otherThing")) while you are waiting for doSomethingAsync to resolve, the active cursor has updated to include "otherThing". When doSomethingAsync resolves, the handler attached to it will update the old cursor (that does not include "otherThing"). The callback will be called with the old store, which does not have "otherThing".

This bug can be hard to diagnose, so cursors will print a "You are updating a stale cursor" warning in the console when a stale cursor is being updated.

The safer version of the code is:

doSomethingAsync().then((function(something){
  this.props.$store.push(something);
}).bind(this));

This ensures that the component uses the most recent version of the store when updating.

API

PUI Cursor provides wrappers for the React immutability helpers. These wrappers allow you to transform the data in your cursor; the transformation you specify is applied and the new result is used to update the cursor value.

get()

Returns your current node

var store = {animals: {lion: 'Larry', seal: 'Sebastian'}};
const $store = new Cursor(store, callback);

The cursor never updates its own data structure, so get is prone to returning stale data.

If you execute $store.refine('animals', 'lion').set('Scar').get();, it will return 'Larry' instead of 'Scar'

In general, we recommend that you not use get and instead access the store directly with props. If you want to use get, ensure that you are using the newest version of your Cursor.

set()

Sets the data for your current node. If you call `set at the top of the data tree, it sets the data for every node.

var store = {animals: {lion: 'Larry', seal: 'Sebastian'}};
const $store = new Cursor(store, callback);

If you execute $store.refine('animals').set({lion: 'Simba', warthog: 'Pumba'});, the callback will be called with {animals: {lion: 'Simba', warthog: 'Pumba'}}.

refine()

Changes where you are in the data tree. You can provide refine with multiple arguments to take you deeper into the tree.

If the data node that you're on is an object, refine expects a string that corresponds to a key in the object.

var store = {animals: {lion: 'Larry', seal: 'Sebastian'}};
const $store = new Cursor(store, callback);

For example, $store.refine('animals', 'seal').get();, will return 'Sebastian'.

If the data node that you're on is an array of objects, refine expects an index or an element of the array.

var hey = {greeting: 'hey'};
var hi = {greeting: 'hi'};
var hello = {greeting: 'hello'};
var store = {greetings: [hey, hi, hello]};
const $store = new Cursor(store, callback);

then $store.refine('greetings', 1, 'greeting').get(); will return 'hi'. If you have the element of an array but not the index, $store.refine('greetings', hi, 'greeting').get(); will also return 'hi'.

merge()

Merges data onto the object at your current node

$store.refine('animals').merge({squirrel: 'Stumpy'});

The callback will be called with {animals: {lion: 'Larry', seal: 'Sebastian', squirrel: 'Stumpy'}}.

push()

Pushes to the array at your current node

var hey = {greeting: 'hey'};
var hi = {greeting: 'hi'};
var hello = {greeting: 'hello'};
var yo = {grettings: 'yo'};
var store = {greetings: [hey, hi, hello]};
const $store = new Cursor(store, callback);

If you execute $store.refine('greetings').push({greeting: 'yo'});, the callback will be called with {greetings: [hey, hi, hello, yo]}.

apply()

If the simpler functions like set, merge, or push cannot describe the update you need, you can always call apply to specify an arbitrary transformation.

Example:

var currentData = {foo: 'bar'};
var cursor = new Cursor(currentData, function(newData){ this.setState({data: newData}); });
cursor.apply(function(shallowCloneOfOldData) {
  shallowCloneOfOldData.foo += 'bar';
  return shallowCloneOfOldData;
});

Warning: The callback for apply is given a shallow clone of your data (this is the behavior of the apply function in the React immutability helpers). This can cause unintended side effects, illustrated in the following example:

var currentData = {animals: {mammals: {felines: 'tiger'}}};
var cursor = new Cursor(currentData, function(newData){ this.setState({data: newData}); });

cursor.apply(function(shallowCloneOfOldData) {
  shallowCloneOfOldData.animals.mammals.felines = 'lion';
  return shallowCloneOfOldData;
});

Since the data passed into the callback is a shallow clone of the old data, values that are nested more than one level deep are not copied, so shallowCloneOfOldData.animals.mammals will refer to the exact same object in memory as currentData.animals.mammals.

The above version of apply will mutate the previous data in the cursor (currentData) in addition to updating the cursor. As a side effect, shallow compare will not detect any changes in the data when it compares previous props and new props. To safely use apply on nested data, you need to use the React immutability helpers directly:

var reactUpdate = require('react/lib/update');

cursor.apply(function(shallowCloneOfOldData) {
  return reactUpdate.apply(shallowCloneOfOldData, {
    animals: {
      mammals: {
        felines: {$set: 'lion'}
      }
    }
  });
});

remove()

Removes your current node

If the current node is an object and you call remove(key), remove deletes the key-value.

var store = {animals: {lion: 'Larry', seal: 'Sebastian'}};
const $store = new Cursor(store, callback);

If you execute $store.refine('animals', 'seal').remove();, the callback will be called with {animals: {lion: 'Larry'}}.

If the current node is an array:

var hey = {greeting: 'hey'};
var hi = {greeting: 'hi'};
var hello = {greeting: 'hello'};
var store = {greetings: [hey, hi, hello]};
const $store = new Cursor(store, callback);

If you execute $store.refine('greetings').remove(hello), the callback will be called with {greetings: [hey, hi]}.

splice()

Splices an array in a very similar way to array.splice. It expects an array of 3 elements as an argument. The first element is the starting index, the second is how many elements from the start you want to replace, and the third is what you will replace those elements with.

var hey = {greeting: 'hey'};
var hi = {greeting: 'hi'};
var hello = {greeting: 'hello'};
var yo = {greeting: 'yo'};
var store = {greetings: [hey, hi, hello]};
const $store = new Cursor(store, callback);

If you execute $store.refine('greetings').splice([2, 1, yo]);, the callback will be called with {greetings: [hey, hi, yo]}.

unshift()

Adds an element to the start of the array at the current node.

var hey = {greeting: 'hey'};
var hi = {greeting: 'hi'};
var hello = {greeting: 'hello'};
var yo = {greeting: 'yo'};
var store = {greetings: [hey, hi, hello]};
const $store = new Cursor(store, callback);

If you execute $store.refine('greetings').unshift(yo);, the callback will be called with {greetings: [yo, hey, hi, hello]}


(c) Copyright 2016 Pivotal Software, Inc. All Rights Reserved.