A beautifully-simple framework-agnostic modern state management library.
A beautifully-simple framework-agnostic modern state management library.
Proxy
s 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 onChange
or useStore
.Read more about how Store compares against other libraries in the FAQ section below.
npm install --save store@npm:@fabiospampinato/store
store
The first step is wrapping the objects containing the state of your app with the store
function, this way Store will be able to transparently detect when mutations occur.
Example usage:
import {store} from 'store';
const CounterApp = {
store: store ({ value: 0 }),
increment: () => CounterApp.store.value += 1,
decrement: () => CounterApp.store.value -= 1
};
store
can contain a variety of values:
store
will wrap your object with a Proxy
, which will detect mutations, and return a proxied object.store
directly, as those mutations won't be detected, always go through the proxied object returned by store
instead. I'd suggest you to wrap your raw objects with store
immediately so you won't even keep a reference to them.store
as if it was a regular object.{ [Symbol ()]: { undetected: true }
).isStore
This function checks if the passed value is a recognized Proxy
object or not.
Example usage:
import {store, isStore} from 'store';
isStore ( store ( {} ) ); // => true
isStore ( {} ); // => false
isIdle
When no store is passed to it it checks if all known stores have no pending updates, i.e. some changes happened to them and at least one onChange
listener has not been called yet.
If a store is passed it checks only if the passed store has no pending updates.
This is its interface:
function isIdle ( store?: Store ): boolean;
Example usage:
import {store, isIdle} from 'store';
const proxy1 = store ( {} );
const proxy2 = store ( {} );
isIdle (); // => true
isIdle ( proxy1 ); // => true
isIdle ( proxy2 ); // => true
proxy1.foo = true;
isIdle (); // => false
isIdle ( proxy1 ); // => false
isIdle ( proxy2 ); // => true
onChange
Next you'll probably want to listen for changes to your stores, the onChange
function is how you do that in a framework-agnostic way.
This is its interface:
// Single store, without selector, listen to all changes to the store
function onChange ( store: Store, listener: ( data: Store ) => any ): Disposer;
// Multiple stores, without selector, listen to all changes to any store
function onChange ( stores: Store[], listener: ( ...data: Store[] ) => any ): Disposer;
// Single store, with selector, listen to only changes that cause the value returned by the selector to change
function onChange ( store: Store, selector: ( store: Store ) => Data, listener: ( data: Data ) => any ): Disposer;
// Single store, with selector, with comparator, listen to only changes that cause the value returned by the selector to change and the comparator to return true
function onChange ( store: Store, selector: ( store: Store ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, listener: ( data: Data ) => any ): Disposer;
// Multiple stores, with selector, listen to only changes that cause the value returned by the selector to change
function onChange ( stores: Store[], selector: ( ...stores: Store[] ) => Data, listener: ( data: Data ) => any ): Disposer;
// Multiple stores, with selector, with comparator, listen to only changes that cause the value returned by the selector to change and the comparator to return true
function onChange ( stores: Store[], selector: ( ...stores: Store[] ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, listener: ( data: Data ) => any ): Disposer;
store
/stores
argument is either a single proxied object retuned by the store
function or an array of those.listener
argument is the function that will be called when a change to the provided stores occurs. It will be called with the value returned by the selector
, if a selector was provided, or with all the provided stores as its arguments otherwise.selector
optional argument is a function that computes some value that will be passed to the listener as its first argument. It's called with all the provided stores as its arguments.comparator
optional argument is a function that checks for equality between the previous value returned by the selector and the current one.Example usage:
import areShallowEqual from 'are-shallow-equal';
import {store, onChange} from 'store';
const CounterApp = {
store: store ({ value: 0 }),
increment: () => CounterApp.store.value += 1,
decrement: () => CounterApp.store.value -= 1
};
// No selector
const disposer1 = onChange ( CounterApp.store, store => {
console.log ( 'Value changed, new value:', store.value );
disposer1 (); // Preventing this listener to be called again
});
// With selector
const disposer2 = onChange ( CounterApp.store, store => store.value % 2 === 0, isEven => {
console.log ( 'Is the new value even?', isEven );
});
// With selector, with comparator
const disposer = onChange ( CounterApp.store, store => ({ sqrt: Math.sqrt ( store.value ) }), areShallowEqual, ({ sqrt }) => {
console.log ( 'The new square root is:', sqrt );
});
CounterApp.increment (); // This will cause a mutation, causing the listeners to be called
CounterApp.increment (); // This will cause another mutation, but the listeners will still be called once as these mutations are occurring in a single event loop tick
setTimeout ( CounterApp.increment, 100 ); // This will cause the remaining listener to be called again
listener
s are automatically coalesced and batched together for performance, so if you synchronously, i.e. within a single event loop tick, mutate a store multiple times and there's a listener listening for those changes that listener will only be called once.batch
Synchronous mutations, i.e. mutations that happen within a single event loop tick, are batched and coalesced together automatically, if you sometimes also want to batch and coalesce mutations happening inside an asynchronous function or two arbitrary points in time you can use the batch
function.
This is its interface:
function batch<P extends Promise<any>> ( fn: () => P ): P;
// Helper methods
batch.start = function (): void;
batch.stop = function (): void;
Example usage:
import {batch, store} from 'store';
const myStore = store ( { foo: 123 } );
// Function-based batching
batch ( async () => {
myStore.foo = 0;
await someAsyncFunction ();
myStore.foo = 1;
});
// Manual batching
batch.start ();
for ( const nr of [1, 2, 3, 4, 5] ) {
myStore.foo = nr;
await someAsyncFunction ();
}
batch.stop ();
batch
everything is taken care of for you: if the passed function throws batching is stopped automatically, nested batch
calls are not a problem either.batch.start
and batch.stop
you have to make sure that batch.stop
is always called the same number of times that batch.start
was called, or batching will never stop. So make sure that for instance thrown errors or early exits are not an issue.target
This function unwraps a store and returns the raw plain object used under the hood.
This is its interface:
function target<T> ( store: T ): T;
Example usage:
import {store, target, isStore} from 'store';
const myStore = store ( { foo: 123 } );
const rawObject = target ( myStore );
isStore ( myStore ); // => true
isStore ( rawObject ); // => false
debug
debug
provides a simple way to access your stores and see at a glance how and when they change from the DevTools.
This is its interface:
type Global = {
stores: Store[], // Access all stores
log: () => void // Log all stores
};
type Options = {
collapsed: true, // Whether the logged groups should be collapsed
logStoresNew: false, // Whether to log new store that have been created
logChangesDiff: true, // Whether to log diffs (added, updated, removed) state changes
logChangesFull: false // Whether to log the previous and current state in their entirity
};
function debug ( options?: Options ): Global;
Example usage:
import {debug} from 'store';
debug ();
Once called, debug
defines a global object named STORE
, which you can then access from the DevTools, and returns it.
Example usage:
STORE.stores[0].value += 1; // Manually triggering a mutation
STORE.log (); // Logging all stores to the console
debug
before creating any stores.debug
only during development, as it may perform some potentially slow computations.Hooks
Hooks
provides a simple way to "hook" into Store's internal events.
Each hook has the following interface:
class Hook {
subscribe ( listener: Function ): Disposer
}
subscribe
registers a function for being called every time that hook is triggered.
These are all the currently available hooks:
const Hooks = {
store: {
change: Hook, // Triggered whenever a store is mutated
changeBatch: Hook, // Triggered whenever a store is mutated (batched)
new: Hook // Triggered whenever a new store is created. This hook is used internally for implementing `debug`
}
};
Example usage:
import {Hooks} from 'store';
const disposer = Hooks.store.new.subscribe ( store => {
console.log ( 'New store:', store );
});
disposer ();
If you need some more hooks for your Store plugin let me know and I'll make sure to add them.
We currently don't have an official "Store DevTools Extension", but it would be super cool to have one. Perhaps it could provide a GUI for debug
's functionalities, and/or implement other features like time-travel debugging. If you're interested in developing this please do get in touch! 😃
These extra features, intended to be used with React, are available from a dedicated subpackage.
useStore
useStore
is a React hook for accessing a store's, or multiple stores's, values from within a functional component in a way that makes the component re-render whenever those values change.
This is its interface:
// Single store, without selector, re-render after any change to the store
function useStore ( store: Store ): Store;
// Multiple stores, without selector, re-render after any change to any store
function useStore ( stores: Store[] ): Store[];
// Single store, with selector, re-render only after changes that cause the value returned by the selector to change
function useStore ( store: Store, selector: ( store: Store ) => Data, dependencies: ReadonlyArray<any> = [] ): Data;
// Single store, with selector, with comparator, re-render only after changes that cause the value returned by the selector to change and the comparator to return true
function useStore ( store: Store, selector: ( store: Store ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, dependencies: ReadonlyArray<any> = [] ): Data;
// Multiple stores, with selector, re-render only after changes that cause the value returned by the selector to change
function useStore ( stores: Store[], selector: ( ...args: Store[] ) => Data, dependencies: ReadonlyArray<any> = [] ): Data;
// Multiple stores, with selector, with comparator, re-render only after changes that cause the value returned by the selector to change and the comparator to return true
function useStore ( stores: Store[], selector: ( ...args: Store[] ) => Data, comparator: ( dataPrev: Data, dataNext: Data ) => boolean, dependencies: ReadonlyArray<any> = [] ): Data;
store
/stores
argument is either a single proxied object retuned by the store
function or an array of those.selector
optional argument if a function that computes some value that will be the return value of the hook. It's called with all the passed stores as its arguments.comparator
optional argument is a function that checks for equality between the previous value returned by the selector and the current one.dependencies
optional argument is an array of dependencies used to inform React about any objects your selector function will reference from outside of its innermost scope, ensuring the selector gets called again if any of those change.selector
returns, if a selector was provided, or the entire store if only one store was provided, or the entire array of stores otherwise.Example usage:
import areShallowEqual from 'are-shallow-equal';
import {store, onChange} from 'store';
import {useStore} from 'store/x/react';
const CounterApp = {
store: store ({ value: 0 }),
increment: () => CounterApp.store.value += 1,
decrement: () => CounterApp.store.value -= 1
};
// No selector
const CounterComponent1 = () => {
const {value} = useStore ( ConunterApp.store );
return (
<div>
<div>{value}</div>
<button onClick={CounterApp.increment}>Increment</button>
<button onClick={CounterApp.decrement}>Decrement</button>
</div>
)
};
// With selector
const CounterComponent2 = () => {
const isEven = useStore ( ConunterApp.store, store => store.value % 2 === 0 );
return (
<div>
<div>Is the value even? {isEven}</div>
<button onClick={CounterApp.increment}>Increment</button>
<button onClick={CounterApp.decrement}>Decrement</button>
</div>
)
};
// With selector, with comparator
const CounterComponent3 = () => {
const {sqrt} = useStore ( ConunterApp.store, store => ({ sqrt: Math.sqrt ( store.value ) }), areShallowEqual );
return (
<div>
<div>The square root is: {sqrt}</div>
<button onClick={CounterApp.increment}>Increment</button>
<button onClick={CounterApp.decrement}>Decrement</button>
</div>
)
};
useStore
hook, in order to make the component re-render whenever any of the retireved values change.useStore
for accessing methods that mutate the store, you can just reference them directly.useStores
useStores
is just an alias for useStore
, this alias is provided in case passing multiple stores to an hook called useStore
doesn't feel quite right to you.
Example import:
import {useStores} from 'store/x/react';
I'll personally use this library over more popular ones for a few reasons:
store
, you won't have to specially handle asynchronicity, and you won't have to carefully update your stores in an immutable fashion.You might not want to use Store if: the design choices I made don't resonate with you, you need something more battle-tested, you need to support some of the ~5% of the outdated browsers where Proxy
isn't available, or you need the absolute maximum performance from your state management library since you know that will be your bottleneck, which is very unlikely.
MIT © Fabio Spampinato