@immutabl3/store

1.3.0 • Public • Published

@immutabl3/store

Store is a modern, Proxy-based JavaScript data store supporting cursors and enabling developers to easily navigate and monitor nested data though events

It's a combination and evolution of the work done in fabiospampinato/store and Yomguithereal/baobab with a focus on performance (especially pertaining to data changes) and size with a loosely coupled API

It aims at providing a centralized model to hold an application's state and can be paired with React easily through hooks and higher order components

Install

npm install @immutabl3/store

store is ~4.47kb minified and gzipped

Quick Start

import Store from '@immutabl3/store'

// initialize the store
const store = Store({
  palette: {
    colors: ['green', 'red'],
    name: 'Glorious colors'
  }
});

// listen to all changes in the store
store.watch(({ transactions }) => {
  console.log('the store has been updated!', transactions);
});

// data is the object passed to Store, wrapped in a Proxy
const { data } = store;

// manipulate the data as plain-old-javascript
data.palette.colors.push('blue');
> ['green', 'red', 'blue']
  
// type checks work as well
Array.isArray(data.palette.colors);
> true

Summary

Usage

instantiation

Creating a store is as simple as instantiating Store with an initial data set.

import Store from '@immutabl3/store';

const store = Store({ hello: 'world' });

// data is your store's data
store.data
> {hello: "world"}

An options object can be passed as a second parameter to the store to change behavior:

  • asynchronous, default: true - whether events should be fired asynchonously
  • autoCommit, default: true - whether the store should automatically trigger changes when the data is changed
  • debug, default: undefined - the logger for tracking changes

cursors

You can create cursors to easily access nested data in your store and listen to changes concerning the part of the store selected

// considering the following store
const store = Store({
  palette: {
    name: 'fancy',
    colors: ['blue', 'yellow', 'green'],
  },
});

// creating a cursor on the palette
var paletteCursor = store.select(['palette']);
paletteCursor.get();
> {name: 'fancy', colors: ['blue', 'yellow', 'green']}

// creating a cursor on the palette's colors
var colorsCursor = store.select(['palette', 'colors']);
colorsCursor.get();
> ['blue', 'yellow', 'green']

// creating a cursor on the palette's third color
var thirdColorCursor = store.select(['palette', 'colors', 2]);
thirdColorCursor.get();
> 'green'

// note that you can also perform subselections if needed
const colorCursor = paletteCursor.select('colors');

watch

A store can be watched for changes

const store = Store({
  user: {
    name: 'John',
  },
});

const { data } = store;

// will fire when the store changes
store.watch(() => {
	console.log(`user's name is ${data.user.name}`);
  > `user's name is Jane`
});

data.user.name = 'Jane';

cursors can be watched as well. A cursor's change event will only fire if the target object has changed

const store = Store({
  user: {
    name: 'John',
  },
});

// listen to the user
const userCursor = store.select(['user']);
userCursor.watch(() => {
  console.log(`user's name is ${userCursor.data.name}`);
  > `user's name is Jane`
});

// listen to a specific value
store.select(['user', 'name'])
  .watch(() => {
    console.log(`user's name is ${store.data.user.name}`);
		> `user's name is Jane`
  });

// change the data at the cursor level
cursor.data.name = 'Jane';
// or at the store level
store.data.user.name = 'Jane';

watch returns a disposer. When called, the disposer will unbind the function

const store = Store({ counter: 1 });

const dispose = store.watch(() => {
  console.log(store.data.counter);
});

store.data.counter = 2;
> 2

dispose();

store.data.counter = 3;
// event is not called

watch can take a selector to watch one or more values

const store = Store({
  user: {
    name: 'John',
  },
});

// listen to the name change
store.watch(['user', 'name'], () => {
  console.log(`user's name is ${store.data.user.name}`);
  > `user's name is Jane`
});

store.data.user.name = 'Jane';

An object can be used to listen to multiple values. Each key of the object will be mapped to the changed data (for more info, see Events)

const store = Store({
  user: {
    name: 'John',
    age: 50,
  },
});


store.watch({
  person: ['user', 'name'],
  years: ['user', 'age'],
// event will fire when either user.name or user.age change
}, e => {
  console.log(`${e.data.person} is ${e.data.years} years old`);
  > `Jane is 30 years old`
});

store.data.user.name = 'Jane';

watch returns a disposer. When called, the disposer will unbind the function

const store = Store({ counter: 1 });

const dispose = store.watch(['counter'], e => {
  console.log(e.data);
});

store.data.counter = 2;
> 2

dispose();

store.data.counter = 3;
// event is not called

project

project takes an object with paths and saturates the object with the current state of the store

const store = Store({
  user: {
    name: 'John',
    age: 50,
  },
});


const result = store.project({
  person: ['user', 'name'],
  years: ['user', 'age'],
});

console.log(`${result.person} is ${result.years} years old`);
> `Jane is 30 years old`

events

Every listener is passed an event object. The event contains:

data

Contains the data for the selector passed - pertinent if using watch

const store = Store({ hello: 'universe' });

store.watch(['hello'], e => {
	e.data === 'world'
  > true
});

store.data.hello = 'world;

For an watch event, this is the same as target

transactions

A list of all changes made to the object (and its children) since the last event. Each transaction tracks the mutations made to the object sequentially, tracking the type of operation, the path of the change and the value/args used to make the change.

const store = Store({
  val: 0,
  arr: [0],
});

store.watch(['hello'], e => {
	console.log(e.transactions);
	/* 
  [
    {
      type: 'set',
      path: ['val'],
      value: 1,
    },
    {
      type: 'push',
      path: ['arr'],
      value: [1],
    }
  ]
  */
});

store.data.val = 1;
store.data.arr.push(1);

Using a cursor or watching values will only report transactions pertinent to that position in the store

gets

Store comes with convenient pure functions for accessing nested data from the store.

get

Gets the value from the store

const store = Store({
  palette: {
    name: 'fancy',
    colors: ['blue'],
    list: [{ item: 1, value: ['black'] }],
  },
});

// getting a path
store.get(['palette']);
> {name: 'fancy', colors: ['blue']}

// getting a cursor
store.select(['palette']).get();
> {name: 'fancy', colors: ['blue']}

// the path can be dynamic
store.get(['palette', 'list', { item: 1 }, 'value', 0]);
> 'black'

exists

Check whether a specific path exists within the data.

// true
store.exists();

// does the cursor point at an existing path?
cursor.exists();

// can also take a path
store.exists('hello');
store.exists(['hello', 'message']);

clone

Shallow clone the cursor's data. The method takes an optional nested path.

const store = Store({ user: {name: 'John' } }),
const cursor = store.select('user');

assert(cursor.get() !== cursor.clone());

updates

Store comes with a set of convenient pure functions for updating data. These updates write to the data synchronously, even if watch events update asynchronously.

set

Replaces value at the given path. Will also work if you want to replace a list's item.

// setting a value
const value = cursor.set('key', newValue);

// can also use a dynamic path
const value = cursor.set(['one', { id: 'two'}, 0], newValue);

// setting a cursor
const value = cursor.set(newValue);

unset

Unsets the given key. Will also work if you want to delete a list's item.

// removing a value
cursor.unset(['one', 'two']);

// can also use a dynamic path
cursor.unset(['one', { id: 'two'}, 0]);

// removing data at cursor
cursor.unset();

push

Pushes a value into the selected list. Will fail if the selected node is not a list.

// pushing a value
const list = cursor.push(['arr'], newValue);

// can also use a dynamic path
const list = cursor.push(['one', { id: 'two'}, 'arr'], newValue);

// pushing a cursor
const list = cursor.push(newValue);

unshift

Unshifts a value into the selected list. Will fail if the selected node is not a list.

// unshift a value
const list = cursor.unshift(['arr'], newValue);

// can also use a dynamic path
const list = cursor.unshift(['one', { id: 'two'}, 'arr'], newValue);

// unshift a cursor
const list = cursor.unshift(newValue);

concat

Concatenates a list into the selected list. Will fail if the selected node is not a list.

// concatenating a list at the given path
const list = cursor.concat(['key'], list);

// can also use a dynamic path
const list = cursor.unshift(['one', { id: 'two'}, 'arr'], list);

// concatenating a cursor
const list = cursor.concat(list);

pop

Removes the last item of the selected list. Will fail if the selected node is not a list.

// popping a list at the given path
const value = cursor.pop(['key']);

// can also use a dynamic path
const value = cursor.pop(['one', { id: 'two'}, 'arr']);

// popping a cursor
const value = cursor.pop();

shift

Removes the first item of the selected list. Will fail if the selected node is not a list.

// shifting a list at the given path
const value = cursor.shift(['key']);

// can also use a dynamic path
const value = cursor.shift(['one', { id: 'two'}, 'arr']);

// shifting a cursor
const value = cursor.shift();

splice

Splices the selected list. Will fail if the selected node is not a list.

The splice specifications works the same as for Array.prototype.splice. There is one exception though: Per specification, splice deletes no values if the deleteCount argument is not parseable as a number. Instead store throws an error if the given deleteCount argument could not be parsed.

// splicing the list
const list = cursor.splice([1, 1]);

// omitting the deleteCount argument makes splice delete no elements
const list = cursor.splice([1]);

// inserting an item
const list = cursor.splice([1, 0, 'newItem']);
const list = cursor.splice([1, 0, 'newItem1', 'newItem2']);

// splicing the list at key
const list = cursor.splice('key', [1, 1]);

// splicing list at path
const list = cursor.splice(['one', 'two'], [1, 1]);
const list = cursor.select('one', 'two').splice([1, 1]);
const list = cursor.select('one').splice('two', [1, 1]);

merge

Shallow merges the selected object with another one. This will fail if the selected node is not an object.

// Merging
const newList = cursor.merge({ name: 'John' });

// Merging at key
const newList = cursor.merge('key', { name: 'John' });

// Merging at path
const newList = cursor.merge(['one', 'two'], { name: 'John' });
const newList = cursor.select('one').merge('two', { name: 'John' });

debug

The debugger is a separate module that can be configured and passed to the store to enable debugging. It will log updates, additions and deletions between the previous and new state on commit.

It's not recommended to use debug in production, as it clones the store state on every commit and increases code size.

import Store from '@immutabl3/store';
import debug from '@immutabl3/store/debug';

debug can be passed on options object:

  • diffs, default: true - whether to log the diffs between the old and new state
  • full, default: false - whether to log the entirety of the old and new state
  • collapsed, default: true - will call log.groupCollapsed when true, log.group when false
  • log, default: console - what to use to log the debug statements. Overwriting this will need to implement the following console methods: log, group, groupCollapsed and groupEnd

React

React integration can be done with hooks or higher-order components. Note that higher-order components implements hooks under-the-hood. See peerDependencies in the package.json for supported React versions

Hooks

####Creating the app's state

Let's create a store for our colors:

state.js

import Store from '@immutabl3/store';

export default Store({
  colors: ['yellow', 'blue', 'orange']
});

Exposing the store

Now that the store is created, we should bind our React app to it by using a context.

Under the hood, this component will simply propagate the store to its descendants using React's context so that components may get data and subscribe to updates.

main.jsx

import React from 'react';
import { render } from 'react-dom';
import { useContext } from '@immutabl3/store/react';
import store from './state';

// we will write this component later
import List from './list.jsx';

// creating our top-level component
const App = function({ store }) {
  // useContext takes the store and provides a component bound to the store
  const Context = useContext(store);
  return (
    <Context>
      <List />
    </Context>
  );
};

// render the app
render(<App store={ store } />, document.querySelector('#mount'));

Accessing data

Now that we have access to the top-level store, let's create the component displaying our colors.

list.jsx

import React from 'react';
import { useStore } from '@immutabl3/store/react';

const List = function() {
  // branch by mapping the desired data to cursors
  let { colors } = useStore({
    colors: ['colors'],
  });
  
  // or get a speific value using a single cursor
	colors = useStore(['colors']);

  const renderItem = color=> <li key={color}>{color}</li>;

  return <ul>{colors.map(renderItem)}</ul>;
}

export default List;

Our app would now render something of the kind:

<div>
  <ul>
    <li>yellow</li>
    <li>blue</li>
    <li>orange</li>
  </ul>
</div>

But let's add a new color to the list:

import store from './state';
store.data.colors.push('purple');

And the list component will automatically update and to render the following:

<div>
  <ul>
    <li>yellow</li>
    <li>blue</li>
    <li>orange</li>
    <li>purple</li>
  </ul>
</div>

HOC

Creating the app's state

Let's create a store for our colors:

state.js

import Store from '@immutabl3/store';

export default Store({
  colors: ['yellow', 'blue', 'orange']
});

Exposing the store

Now that the store is created, we should bind our React app to it. Under the hood, this component will simply propagate the store to its descendants using React's context.

main.jsx

import React from 'react';
import { render } from 'react-dom';
import { root } from '@immutabl3/store/react';
import store from './state';

// we will write this component later
import List from './list.jsx';

// creating our top-level component
const App = () => <List />;

// lets's bind the component to the store through the `root` higher-order component
const RootedApp = root(store, App);

// render the app
render(<RootedApp />, document.querySelector('#mount'));

Accessing the data

Now that we have "rooted" our top-level App component, let's create the component displaying our colors and branch it from the root data.

list.jsx

import React from 'react';
import { branch } from '@immutabl3/store/react';

// thanks to the branch, our colors will be passed as props to the component
const List = function({ colors }) {
  const renderItem = color => <li key={color}>{color}</li>;

  return <ul>{colors.map(renderItem)}</ul>;
};

// branch the component by mapping the desired data to cursors
export default branch({
  colors: ['colors'],
}, List);

Our app would now render something of the kind:

<div>
  <ul>
    <li>yellow</li>
    <li>blue</li>
    <li>orange</li>
  </ul>
</div>

But let's add a new color to the list:

import store from './state';
store.data.colors.push('purple');

And the list component will automatically update and to render the following:

<div>
  <ul>
    <li>yellow</li>
    <li>blue</li>
    <li>orange</li>
    <li>purple</li>
  </ul>
</div>

Dynamically set the list's path using props

Sometimes, you might find yourself needing cursors paths changing along with your component's props.

For instance, given the following state:

state.js

import Store from '@immutabl3/store';

export default Store({
  colors: ['yellow', 'blue', 'orange'],
  alternativeColors: ['purple', 'orange', 'black']
});

You might want to have a list rendering either one of the colors' lists.

Fortunately, you can do so by passing a function taking the props of the components and returning a valid mapping:

list.jsx

import React from 'react';
import { branch } from '@immutabl3/store/react';

const List = function({ colors }) {
  const renderItem = color => <li key={color}>{color}</li>;

  return <ul>{colors.map(renderItem)}</ul>;
};

// using a function so that your cursors' path can use the component's props
export default branch(props => {
  return {
    colors: [props.alternative ? 'alternativeColors' : 'colors'],
  };
}, List);

MobX style observers

Store supports MobX style observers with both classes and pojos with support for deeply nested values

class Ticker {
  constructor() {
    this.value = 0;

    setInterval(() => {
      this.next();
    }, 1000);
  }
  next() {
    this.value++;
  }
}

const ticker = observe(new Ticker());

const TickerView = observer(({ ticker }) => {
  <span>Value: { ticker.value }</span>
});

const root = ReactDOM.createRoot(document.body);
root.render(<TickerView ticker={ ticker } />);

Features

  • Simple: there's barely anything to learn and no boilerplate code required. Thanks to the usage of Proxys you just have to wrap your state with store, mutate it and retrieve values from it just like if it was a regular object, and listen to changes via watch

  • Framework-agnostic: Store doesn't make any assuptions about your UI framework of choice and can be used without one

  • React support: both hooks and HOCs are provided for React (in a separate entry point)

Philosophy

Simple APIs

Because the data is proxied, it doesn't need boilerplate, to confirm to a specific object shape, a class or add a dispatcher to your data. Store just wraps the data and allows you to watch for changes. Simple.

More than just data

While many stores only support JSON data or need to comform to a certain structure, the shortcomings of that strategy become transparent: observables, setState, computed data etc... Store supports every valid JavaScript object without needing to alter the data/wrapper to accommodate: use getters, functions, maps, promises etc... Everything short of circular references is supported.

Pure functions

Functional gets and sets are provided for easy and consistent access to the data - but are entirely optional.

Why not using Baobab, Redux, Unstated, react-easy-state etc...?

No reason. Pick whatever library suites your tastes. We try to keep store as fast and battle-tested as possible.

MobX style observables

Stay object oriented by wrapping objects with observable MobX-style before adding to the store to get change events from your pojos and classes.

Why not using Store?

If you're targeting older browsers, if Proxy isn't available or you don't want to polyfill your environment.

Notes

There are two scenarios that store cannot currently handle:

  • Circular References: the objects' references mutate, however, the watchers may not fire and transactions will likely have incorrect pathing. If you know a way of solving this issue, please send a pull request!
  • Array Length: watching an array's length won't trigger updates when the array changes. Instead, watch the array directly. This may be fixed in a future version

Test

To run tests:

  1. Ensure your environment is up-to-date with the engines defined in the package.json

  2. Clone and install the repo

    git clone git@github.com:immutabl3/store.git
    cd store
    npm install
  3. Run the tests

    npm test

Benchmark

speed test

creation x 1,210,361 ops/sec ±0.73% (89 runs sampled)
gets: direct access x 248,558 ops/sec ±1.53% (90 runs sampled)
gets: path x 4,200,374 ops/sec ±0.95% (90 runs sampled)
sets: direct access x 211,038 ops/sec ±0.81% (89 runs sampled)
sets: path x 119,794 ops/sec ±1.05% (91 runs sampled)
change x 192,827 ops/sec ±0.97% (93 runs sampled)
watch x 180,701 ops/sec ±1.18% (90 runs sampled)
project x 1,070,100 ops/sec ±0.69% (93 runs sampled)
select x 20,046,136 ops/sec ±0.82% (92 runs sampled)

comparison test

get: access
store x 251,561 ops/sec ±0.63% (90 runs sampled)
fabio x 843,553 ops/sec ±1.19% (87 runs sampled)
fastest: fabio

get: path
store x 3,840,752 ops/sec ±1.01% (87 runs sampled)
baobab x 667,272 ops/sec ±0.86% (84 runs sampled)
fastest: store

set: access
store x 227,234 ops/sec ±1.13% (88 runs sampled)
fabio x 669,072 ops/sec ±2.56% (82 runs sampled)
fastest: fabio

set: path
store x 117,117 ops/sec ±1.04% (90 runs sampled)
baobab x 266,220 ops/sec ±0.45% (92 runs sampled)
fastest: baobab

change
store x 347,147 ops/sec ±8.21% (22 runs sampled)
baobab x 590 ops/sec ±0.90% (79 runs sampled)
fabio x 295 ops/sec ±0.82% (75 runs sampled)
fastest: store

watch
store x 171,076 ops/sec ±2.29% (88 runs sampled)
baobab x 138,247 ops/sec ±0.39% (89 runs sampled)
fabio x 97,613 ops/sec ±1.22% (80 runs sampled)
fastest: store

project
store x 1,030,856 ops/sec ±0.49% (90 runs sampled)
baobab x 700,897 ops/sec ±0.46% (88 runs sampled)
fastest: store

select
store x 17,189,521 ops/sec ±1.38% (89 runs sampled)
baobab x 594,877 ops/sec ±1.84% (83 runs sampled)
fastest: store

complex selectors
store x 1,768,293 ops/sec ±1.32% (85 runs sampled)
baobab x 575,746 ops/sec ±0.44% (92 runs sampled)
fastest: store

To setup for the benchmarks:

  1. Ensure your environment is up-to-date with the engines defined in the package.json

  2. Clone and install the repo

    git clone git@github.com:immutabl3/store.git
    cd store
    npm install
  3. Run the build

    npm run build

There are three benchmark scripts:

  • npm run bench:mark - Runs benchmarks for store operations and is used to track performance degradation when implementing features
  • npm run bench:compare - Compares store against similar features in other libraries and was used to test if the store was competitive to alternatives in its early stages. Additions or corrections are welcome.
  • npm run bench:micro - microbenchmarks for competing implementations and optimizing hotpaths

Contribution

See CONTRIBUTING.md

License

MIT

Package Sidebar

Install

npm i @immutabl3/store

Weekly Downloads

3

Version

1.3.0

License

MIT

Unpacked Size

321 kB

Total Files

72

Last publish

Collaborators

  • immutabl3