@immutabl3/store
v1.3.0
Published
a simple, modern state management library
Downloads
17
Maintainers
Readme
@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.47
kb 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 calllog.groupCollapsed
whentrue
,log.group
whenfalse
- log, default:
console
- what to use to log the debug statements. Overwriting this will need to implement the following console methods:log
,group
,groupCollapsed
andgroupEnd
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 withstore
, mutate it and retrieve values from it just like if it was a regular object, and listen to changes viawatch
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:
Ensure your environment is up-to-date with the
engines
defined in the package.jsonClone and install the repo
git clone [email protected]:immutabl3/store.git cd store npm install
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:
Ensure your environment is up-to-date with the
engines
defined in the package.jsonClone and install the repo
git clone [email protected]:immutabl3/store.git cd store npm install
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 featuresnpm 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