immutable-cursors
v0.1.8
Published
Provides cursors for Facebook's ImmutableJS library.
Downloads
1,372
Readme
Immutable Cursors
0.1.8
This CommonJS module provides cursors for Facebook's ImmutableJS library. It is essentially a standalone fork of the excellent contrib/cursor module that ships with every distribution of ImmutableJS.
From their README:
Cursors allow you to hold a reference to a path in a nested immutable data structure, allowing you to pass smaller sections of a larger nested collection to portions of your application while maintaining a central point aware of changes to the entire data structure: an onChange function which is called whenever a cursor or sub-cursor calls update.
This is particularly useful when used in conjuction with component-based UI libraries like React or to simulate "state" throughout an application while maintaining a single flow of logic.
This pretty much sums it up.
This module is for most parts a contrib/cursor port to ES2015 including some minor additions and refactorings in order to provide a more extension-friendly and concise interface.
The CommonJS ES5/ES3 distribution is built with Babel.
Getting started
The cursors implement the complete API of KeyedSeq and IndexedSeq respectively, so if you're familiar with ImmutableJS you should have no problems jumping right in. If not, you should probably have a glance at their guides first.
Install and setup
Install the package from npm:
npm install immutable-cursors
Import the module and provide some state:
import Immutable from 'immutable';
import Cursor from 'immutable-cursors';
let data = Immutable.fromJS({
name: {
first: 'Luke',
last: 'Skywalker'
},
age: 35
});
Basic operations
Retrieve an initial cursor using Cursor.from:
let cursor = Cursor.from(data);
cursor.getIn(['name', 'last']);
// 'Skywalker'
Retrieve nested initial state:
let cursor = Cursor.from(data, ['name']);
cursor.get('last')
// 'Skywalker'
Access the ImmutableJS value that is backing the cursor directly:
let cursor = Cursor.from(data, ['name']);
data.get('name') === cursor.deref();
// true
Cursors are immutable as well:
let modifiedAgeCursor = cursor.set('age', 45);
cursor.get('age');
// 35
modifiedAgeCursor.get('age');
// 45
Use cursors like regular ImmutableJS objects:
let firstNameOnly = cursor.get('name').take(1);
firstNameOnly.deref().toJS();
// {
// name: 'Luke'
// }
Cursors support value equality (see Immutable.is). This is especially helpful in situations where you want to compare current to new nested state or props in React components, most prominently in shouldComponentUpdate
:
let valueEqualCursor = Cursor.from(data);
cursor === valueEqualCursor;
// false
Immutable.is(cursor, valueEqualCursor);
// true
Immutable.is(valueEqualCursor, data);
// true
If a cursor references a Record object, all of the Record's properties are present on the cursor as well:
let Name = Immutable.Record({
first: 'Luke',
last: 'Skywalker'
});
let person = Immutable.Map({
name: new Name();
});
let cursor = Cursor.from(person, ['name']);
cursor.first;
// 'Luke'
Nested cursors
Retrieve a sub-cursor:
let nameCursor = cursor.cursor(['name']);
nameCursor.get('first');
// 'Luke'
Methods get
and getIn
also return sub-cursors if they don't point to a primitive value:
let nameCursor = cursor.get('name');
nameCursor.get('last');
// 'Skywalker'
Handle change
Cursors and their sub-cursors share a common root state and a change handler that gets called, whenever modifications on the cursor tree occur.
Add a change handler to the initial Cursor.from call:
let cursor = Cursor.from(data, [], (nextState, currentState) => {
let newFirstName = nextState.getIn(['name', 'first']);
let currentFirstName = currentState.getIn(['name', 'first']);
console.log(currentFirstName + ' => ' + newFirstName);
});
cursor.setIn(['name', 'first'], 'Anakin');
// 'Luke => Anakin'
You can intercept the state propagation by returning a state in your change handler to perform validation, rollbacks etc.:
let cursor = Cursor.from(data, ['name'], (nextState, currentState) => {
if (nextState.get('first') === 'Leia') {
return nextState.set('last', 'Organa');
}
});
let anakinCursor = cursor.set('first', 'Anakin');
anakinCursor.get('first');
// 'Anakin'
let leiaCursor = cursor.set('first', 'Leia');
leiaCursor.get('last');
// 'Organa'
Simplistic React example
Note that in a production environment you hardly want to modify cursors in your components directly. We do that here for the sake of simplicity.
import React from 'react';
import Immutable from 'immutable';
import Cursor from 'immutable-cursors';
let data = Immutable.fromJS({
name: {
first: 'Luke',
last: 'Skywalker'
},
age: 35
});
let app;
class Input extends React.Component {
shouldComponentUpdate(nextProps) {
// This is as easy as it gets
let shouldChange = !this.props.cursor.equals(nextProps.cursor);
console.log('\tShould ' + this.props.name + 'update?', shouldChange)
return shouldChange;
}
onChange(event) {
this.props.cursor.set(this.props.key, event.target.value);
}
render() {
return (
<div>
<label>{this.props.name}</label>
<input
type='text'
value={this.props.cursor.get(this.props.key)}
onChange={this.onChange.bind(this)}
/>
</div>
)
}
}
class Application extends React.Component {
render() {
console.log('\n Render root component.');
return (
<div>
<Input name='First name' cursor={this.props.cursor('name')} key='first' />
<Input name='Last name' cursor={this.props.cursor('name')} key='last' />
<Input name='Age' cursor={this.props.cursor} key='age' />
</div>
);
}
}
function changeHandler(nextState) {
app.setProps(Cursor.from(nextState, changeHandler));
}
app = React.render(
<Application cursor={Cursor.from(nextState, changeHandler)} />,
document.body
);
Development
Get the source:
git clone https://github.com/lukasbuenger/immutable-cursors
Install dependencies:
npm install
Lint the code:
npm run lint
Run the tests:
npm test
Build ES5/ES3:
npm run build
Build the docs / README:
npm run docs
Update all local dependencies:
npm run update-dependencies
Docs
class API
The main entry point for both client and internals. If you create a new cursor using an API object, a reference of it will get passed to every (sub-)cursor you create from the initial one.
It consists mainly of refactored versions of the non-prototypical private functions found in ImmutableJS's contrib/cursor.
I made them sit in a class construct for extendibility and testability reasons. That way, one can easily roll his/her custom logic by subclassing from API.
The downsides of this approach are that every cursor has to carry another few more references and that more importantly the internal API is not really private anymore.
let cursor = Cursor.from(state, ['data']);
messWithInternals(cursor._api);
API objects, in contrary to contrib/cursor, convert key path arrays to Seq objects internally. This is perfectly fine with the ImmutableJS way of working on nested values and gives a couple of handy methods to work on key paths on top of another level of security because key paths are immutable as well.
Extending The following example shows how you could establish access of nested cursors by using dot-string formatted key paths as well.
import API from 'immutable-cursors/lib/API';
class CustomAPI extends API {
path(...paths) {
if (paths.length === 1 && typeof paths[0] === 'string') {
paths = paths[0].split('.');
}
return super.path(...paths);
}
}
let api = new CustomAPI();
export default api.export();
cursorFrom()
Returns a new default cursor.
Signature:
cursorFrom(
rootData: Immutable.Iterable,
keyPath?: Immutable.Seq|Array<string>,
onChange?: Function
): KeyedCursor|IndexedCursor
Arguments:
rootData
- The state.keyPath
- An optional key path to a substate.onChange
- An optional change handler.
Returns:
- A new cursor.
getCursorClass()
Decides on and returns a cursor class by analyzing value
. Returns IndexedCursor if Iterable.isIndexed(value) === true
, else KeyedCursor.
Signature:
getCursorClass(
value: Immutable.Iterable
): Function
Arguments:
value
- Any value in your state.
Returns:
- The class that should be used to create a new cursor for
value
.
makeCursor()
This is the main cursor factory. You probably should not subclass this method as it gives you all the options you need through its arguments. Instead of subclassing it, you should write your own method and call makeCursor from there with your custom values.
Enforce a custom cursor class
class CustomAPI extends API {
getCustomCursor(rootData, keyPath, onChange) {
return this.makeCursor(rootData, keyPath, onChange, undefined, MyCustomCursorClass);
}
export() {
let api = super.export();
api.getCustom = this.getCustomCursor.bind(this);
return api;
}
}
let api = new CustomAPI();
export default api.export();
Equip cursors with shared options Some of the cursor properties like the change handler or the root data will get shared between all cursors that are derived from the same initial cursor, may it be through updating or retrieving a cursor to a nested state etc. In certain situations it might be helpful to have custom shared values in place.
class CustomAPI extends API {
getWithSharedName(rootData, keyPath, onChange, name) {
return this.makeCursor(rootData, keyPath, onChange, undefined, undefined, {
name: name
});
}
export() {
let api = super.export();
api.getWithSharedName = this.getWithSharedName.bind(this);
return api;
}
}
let api = new CustomAPI();
let cursor = api.getWithSharedName(Immutable.fromJS({foo: 'bar'}), [], undefined, 'fooCursor');
cursor._sharedOptions.name;
// 'fooCursor'
cursor.set('foo', 'baz')._sharedOptions.name;
// 'fooCursor'
cursor.cursor('foo')._sharedOptions.name;
// 'fooCursor'
Signature:
makeCursor(
rootData: Immutable.Iterable,
keyPath?: Immutable.Seq,
onChange?: Function,
value?: Immutable.Iterable|any,
CursorClass?: Function,
sharedOptions?: Object
): KeyedCursor|IndexedCursor
Arguments:
rootData
- An ImmutableJS state.keyPath
- A key path to a nested value.onChange
- A change handler.value
- A value to determine the size and theCursorClass
if not present. Default:rootData.getIn(keyPath)
.CursorClass
- Enforce a custom class to create the cursor with.sharedOptions
- Pass additional shared options.
Returns:
- A new cursor
updateCursor()
Updates the current state with changeFn
and calls the cursors change handler. Returns a new cursor backed by either the return value of the change handler or the result of changeFn
.
Signature:
updateCursor(
cursor: KeyedCursor|IndexedCursor,
changeFn: Function,
changeKeyPath?: Immutable.Seq
): KeyedCursor|IndexedCursor
Arguments:
cursor
- The cursor to update.changeFn
- A function that performs and returns modifications on the given state.changeKeyPath
- If present, indicates a deep change.
Returns:
- An new updated cursor.
NOT_SET [read-only]
Constant for attempts on nested undefined values.
Returns
An empty object.
wrappedValue()
Returns a sub-cursor if the given value is an Iterable. If not, returns the value itself.
Signature:
wrappedValue(
cursor: KeyedCursor|IndexedCursor,
keyPath: Immutable.Seq,
value: Immutable.isIterable|any
): KeyedCursor|IndexedCursor|any
Arguments:
cursor
- A cursor from which you want to retrieve a sub-cursor in case of>Iterable.isIterable(value)
.keyPath
- The key path where the value resides.value
- The value to analyze.
Returns:
A sub-cursor or the value.
subCursor()
Creates and returns a sub-cursor of cursor
at keyPath
.
Signature:
subCursor(
cursor: KeyedCursor|IndexedCursor,
keyPath: Immutable.Seq,
value: any
)
Arguments:
cursor
- The cursor you want to create a sub-cursor from.keyPath
- The key path to the state your sub-cursor should point at.value
- The value atkeyPath
.
Returns:
A new sub-cursor
defineRecordProperties()
Extends a cursor with Record properties to export the same interface as its backing value. Calls setProp for each property key.
Signature:
defineRecordProperties(
cursor: KeyedCursor|IndexedCursor,
value: Immutable.Record
): KeyedCursor|IndexedCursor
Arguments:
cursor
- The cursor you want to extend.value
- The Record object whose keys should get mapped oncursor
.
Returns:
An extended cursor.
setProp
Defines an alias property on a cursor that delegates to cursor.get(name)
.
Signature:
setProp(
cursor: KeyedCursor|IndexedCursor,
name: string
)
Arguments:
cursor
- The cursor on which you want to have a getter property with namename
.name
- The name of the property
path()
Normalizes and concatenates any passed key paths and returns a single Seq object.
See pathToSeq
Signature:
path(
...paths: Array<Immutable.Seq|Array<string>|string>
): Immutable.Seq
Returns:
A concatenated, validated key path as Seq
export()
Should return an object containing all (bound) functions and properties that you consider public. Recommended use:
// in ./cursor/CustomAPI.js
import API from 'immutable-cursors/lib/API';
export default class CustomAPI extends API {
export() {
let api = super.export();
api.version = '0.1';
return api;
}
}
// in ./cursor/index.js
import CustomAPI from './CustomAPI';
let api = new CustomAPI();
export default api.export();
// in ./client.js
import Cursor from './cursor';
console.log(Cursor.version);
let cursor = Cursor.from(state);
Signature:
export(): Object
Returns:
The client API
mixin BaseCursor
The BaseCursor mixin contains methods that represent shared behavior of both KeyedCursor and IndexedCursor. The reason why these live in a mixin is, that prototypical inheritance on the cursor classes is already occupied by the ImmutableJS base classes KeyedSeq and IndexedSeq respectively.
Most of the methods in this mixin override these original Immutable.Seq interface, where cursor implementation has to decorate / circumvent default ImmutableJS behavior.
If your are interested in how these overrides work, check out the source. This document only lists the methods that are not part of the original ImmutableJS.Seq interface.
deref()
Returns the ImmutableJS object that is backing the cursor.
Signature:
deref(
notSetValue?: any
): Immutable.Iterable
Arguments:
notSetValue
- You'll getnotSetValue
returned if there is no backing value for this cursor.
Returns:
The ImmutableJS backing state of this cursor.
valueOf()
Alias of deref().
set()
If called with a key
and a value
, the substate at key
will be set to value
. If you only provide one argument, the backing state of the cursor itself is set directly to the given argument. This makes this method a little different from the one found on Iterable.
Signature:
set(
key: Array|Immutable.Seq|any,
value?: any
): KeyedCursor|IndexedCursor
Arguments:
key
- Either a key path to the substate you want to modify or a value you want to set directly.value
- A value.
Returns:
The cursor representing the new state.
function extendMixed
This is a tiny helper function that takes any class / function and extends its prototype with whatever mixins you pass.
import mixed from 'immutable-cursors/lib/extendMixed';
class MyClass extends mixed(BaseClass, Mixin1, Mixin2) {
// your class logic
}
Signature:
extendMixed(
ParentClass: Function,
...mixins: Array<Object>
)
Arguments:
ParentClass
- The class you want to extend....mixins
- An arbitrary amount of objects whose properties you want to have on the prototype ofParentClass
.
Returns:
A copy of the parent class with all mixin extensions.
module immutable-cursors
The public API
function from
Returns a new cursor for the given state and key path.
Signature:
from(
state: Immutable.Iterable,
keyPath?: Array<String>|Immutable.Seq,
changeHandler?: Function
): KeyedCursor|IndexedCursor
Arguments:
state
- The root state.keyPath
- The key path that points to the nested state you want to create a cursor for.changeHandler
- A change handler function that gets called whenever changes occur on the cursor itself or on any sub-cursor. Its return value, if!== undefined
, will replacenewState
as new root state of the resulting cursors shared state. It gets called with:newState
- The state after the update.oldState
- The state before the update.keyPath
- An Immutable.Seq key path that indicates where in the state the update occurred.
Returns
A new root cursor
class IndexedCursor
Extends: IndexedSeq Mixins: BaseCursor
Used to represent indexed ImmutableJS values.
Signature:
new IndexedCursor(
rootData: immutable.Iterable,
keyPath: Immutable.Seq,
onChange?: Function,
size?: number,
api: API,
sharedOptions?: Object
)
Arguments:
rootData
- An ImmutableJS state.keyPath
- A key path to a nested value.onChange
- A change handler.size
- A value that should be set as the size of the cursor. Default:rootData.getIn(keyPath)
.api
- A reference to the API object from which the cursor was derived.sharedOptions
- Pass additional shared options.
Returns:
- A new cursor
class KeyedCursor
Extends: KeyedSeq Mixins: BaseCursor
Used to represent keyed ImmutableJS values.
Signature:
new IndexedCursor(
rootData: immutable.Iterable,
keyPath: Immutable.Seq,
onChange?: Function,
size?: number,
api: API,
sharedOptions?: Object
)
Arguments:
rootData
- An ImmutableJS state.keyPath
- A key path to a nested value.onChange
- A change handler.size
- A value that should be set as the size of the cursor. Default:rootData.getIn(keyPath)
.api
- A reference to the API object from which the cursor was derived.sharedOptions
- Pass additional shared options.
Returns:
- A new cursor
function pathToSeq()
Normalizes and concatenates any passed key paths and returns a single Seq object.
Signature:
pathToSeq(
...paths: Array<Immutable.Seq|Array<string>|string>
): Immutable.Seq
Arguments:
...paths
- Any values that you want to merge to a Seq path
Returns:
An Seq path
Changelog
- 0.1.8 - Dependencies updated, especially ImmutableJS. Built against latest Babel version.
- 0.1.7 - Dependencies updated. Built against latest Babel version.
- 0.1.6 - Dependencies updated. Built against latest Babel version.
- 0.1.5 - Forgot to add transpiled files from 0.1.4. Big facepalm!
- 0.1.4 - In order to keep up with contrib/cursor (see commit here), you can now set a cursor value directly. All dependencies updated.
- 0.1.3 - API docs added. Fixed license referencing in package.json courtesy of kemitchell.
- 0.1.2 - Support Record properties.
Roadmap
- [ ] More examples
- [ ] Better test coverage
- [ ] Annotate source Flow
License
It's complicated. See LICENSE file.