easy-data-state
v1.0.0
Published
framework-agnostic data state management
Readme
easy-data-state
Table of Contents
- Introduction
- Usage
- Development
- Performance
- Caveats
Introduction
easy-data-state is a data state manager. The library utilizes a
publish/subscribe model to respond to data state alterations by triggering
respective callback(s) assigned to receive the changing data. easy-data-state
is framework-agnostic and may be used with React, Angular, and other
implementations to translate data modifications to user interface (UI) updates.
The library may also be used just as a store and includes a mechanism to read
nested data. easy-data-state stands at under 400 lines of code and provides a
simpler alternative to some of the mainstream solutions.
Usage
Installation
To fetch the library, run the following command.
npm install --save easy-data-stateDistributed Versions
easy-data-state's default import (from easy-data-state) is either an
EcmaScript (ES) or a CommonJS (as an UMD) module that bundles the source code without
transpilation. The library makes use of private class methods, latest native methods
(e.g., Array's at, Object.hasOwn), and data structures such as Set and Map.
The defaults are provided as such with the expectations that easy-data-state will be
augmented as a dependency to a host project that, in turn, will be transpiled for some
target environment or used, as is, in a browser or server-side environment (e.g.,
Node 20+) that supports the utilized language features.
For those rare circumstances when easy-data-state has to be utilized in older backend
environments or included in a larger bundle without transpilation (for older browsers),
the EcmaScript 5 distributable is available from easy-data-state\es5.
Creating a Data State
Instantiating a Data State Object
Import EasyDataState constructor and create as many data state objects as needed for
an application. One object is sufficient for most cases.
data-state.js
import {EasyDataState} from 'easy-data-state';
export const state = new EasyDataState();some-application-file.js
import {state} from './data-state.js';
//use data state
state.write('loggedIn', false);Instantiating a Configured Data State Object
EasyDataState can be instantiated with the following global options: asArray,
asObject, cloneReadData, cloneWriteData and triggerImmediately. Configurations
passed to the constructor apply to all read(), subscribe(), and write() calls unless
overridden at a method's invocation.
asArray Setting
The flag instructs a value or collection of values to be returned as an array.
import {EasyDataState} from 'easy-data-state';
let state = new EasyDataState({asArray: true});
state.write({loggedIn: true, authorized: false});
state.read(['loggedIn', 'authorized']); // [true, false]asObject Setting
This configuration directs the library to return a value or collection of values as an object.
let state = new EasyDataState({asObject: true});
state.write({loggedIn: true, authorized: false});
state.read('loggedIn'); // {loggedIn: true}cloneReadData setting
The setting is true by default and specifies whether a retrieved state data is to
be deeply cloned before returning.
let state = new EasyDataState({cloneReadData: false});
state.write({auth: {loggedIn: true, authorized: false}});
state.read('auth') === state.read('auth') // truecloneWriteData setting
The configuration is true by default and specifies whether a written data is to be
deeply cloned before being merged with the data state. The setting may be used in
conjunction with cloneReadData to allow distribution of an original datum.
let map = new Map();
let state = new EasyDataState({cloneReadData: false, cloneWriteData: false});
state.write('map', map);
state.read('map') === map; // trueNOTE: It is probably better to create an EasyDataState instance with cloning
defaults and override them (where appropriate) at read() and write() invocations.
let map = new Map();
let state = new EasyDataState();
state.write({map}, {cloneWriteData: false});
state.read('map', {cloneReadData: false}) === map; // truetriggerImmediately Setting
This option is true by default and specifies whether a data listening callback
is to be invoked right after the registration if one of the data it is assigned
to already exists.
let state = new EasyDataState();
state.write({auth: {loggedIn: true}});
let unsubscribe = state.subscribe('auth', (data) => {
console.log(data); // will not trigger until next write() that affects 'auth'
}, {triggerImmediately: false});
unsubscribe();Default Settings
asArray and asObject options are not set (i.e., undefined) internally. By
default, easy-data-state will return data as an object for multiple retrieved
pieces. Whenever only one piece of data state is fetched, it will be returned as is.
let state = new EasyDataState();
state.write({loggedIn: true, authorized: false});
state.read(['loggedIn', 'authorized']); // {loggedIn: true, authorized: false}
state.read('loggedIn'); // trueWorking with Data State
Writing Data
write() writes new or overwrites existing data. The method accepts an address-value pair,
an object of addresses-values, an address-callback pair, or an object of address-value and
address-callback pairs. For the callback arrangements, a function will receive a data subset
addressed by its address and will return the revised subset that is then stored under the address.
A data address can be single- or multi-level. Multi-level addresses include a dot (e.g., auth.name)
or can be expressed as an array (e.g., ['auth', 'name]) and easy-data-state will store an
address's value at the appropriate nesting level. For example, calling state.write('auth.loggedIn', true)
will place loggedIn inside the auth object.
The method also accepts a configuration object that will override cloneWriteData parameter
set at instantiation.
let state = new EasyDataState();
state.write('auth.profile.loggedIn', true);
state.read(); // {auth: {profile: {loggedIn: true}}}let state = new EasyDataState();
state.write({auth: {profile: {loggedIn: true}, 'permissions.name': null}});
state.read(); // {auth: {profile: {loggedIn: true}, permissions: {name: null}}}let state = new EasyDataState();
state.write('visitsCount', (count = 0) => count + 1);
state.read(); // {visitsCount: 1}Writing Custom Data
Some rare situations may call on storing/distributing functions or custom objects
as is. This can be accomplished by turning off write and read cloning and marking
an object as an easy-data-state value.
import {EasyDataState, easyDataStateValueKey} from 'easy-data-state';
let state = new EasyDataState();
let someFunction = () => {};
someFunction[easyDataStateValueKey] = true;
let unsubscribe = state.subscribe('func', (func) => {
//use func() in some way;
}, {cloneReadData: false});
state.write('func', someFunction, {cloneWriteData: false});
unsubscribe();Reading Data
read() reads one or more properties from a data state. To fetch multiple data, an
array of addresses must be provided. The method also accepts a configuration object that
will override asArray, asObject, and cloneReadData parameters set at instantiation.
When specifying multi-level data to be returned as an object, easy-data-state will use
the last part of the multi-level address as the reference under which the fetched datum will
be stored. If the last part of multiple multi-level addresses is the same, then a unique
alias should be provided under which the datum will be stored.
let state = new EasyDataState();
state.write('auth.loggedIn', true);
state.read('auth.loggedIn'); // true
state.read('auth.loggedIn', {asArray: true}); // [true]
state.read('auth.loggedIn', {asObject: true}); // {loggedIn: true}let state = new EasyDataState();
state.write({profile: {name: 'admin', permissions: []}});
state.read(['profile.name', 'profile.permissions']); // {name: 'admin', permissions: []}let state = new EasyDataState();
state.write({profile: {name: 'admin'}, user: {name: 'name'}});
state.read(['profile.name', 'user.name']); // {name: 'name'}
state.read(['profile.name', {'user.name': 'userName'}]) // {name: 'admin', userName: 'name'}Deleting Data
delete() removes one or more properties from a data state. If a property marked for
deletion does not exist, the library will do nothing.
let state = new EasyDataState();
state.write({profile: {name: 'admin', permissions: []}});
state.delete('profile.permissions');
state.read(); // {profile: {name: 'admin'}}let state = new EasyDataState();
state.write({profile: {name: 'admin', permissions: []}});
state.delete(['profile.permissions', 'profile.name']);
state.read(); // {profile: {}}let state = new EasyDataState();
state.write({profile: {name: 'admin'}});
state.delete('profile.permissions');
state.read(); // {profile: {name: 'admin'}}Subscribing to Data Changes
The main feature of the library is registration of callbacks to respond to data state
changes. subscribe() pairs a callback to one or more data state entries. Whenever a
subscribed-to property's value changes, all callbacks bound to it will be invoked. If a
subscription is added for a property for which a value already exists, the callback will
be triggered immediately after the registration. easy-data-state uses read() internally
to fetch data that will be passed to a callback. Callbacks also receive, as a second
parameter, an array of addresses of the just-altered data.
subscribe() can be called with options that direct how the data is to be processed and
packaged. Registered callbacks are triggered by write() and delete() calls. easy-data-state
uses strict equality (===) to check if the new value is different from the existing.
Callbacks are run when strict non-equality is satisfied. delete() operations that change
the data state always trigger the respective callbacks. If, during registration, one of the
subscribing-to data already exists, then a callback will be immediately fired. Set
triggerImmediately configuration to false to prevent such invocation. subscribe()
returns an unsubscription function.
easy-data-state supports global subscriptions. Callbacks registered without an explicit
data address(es) will respond to all data state changes. Such listening may be useful for
logging purposes.
let state = new EasyDataState();
let unsubscribe = state.subscribe(['items', 'auth.loggedIn'], (data) => {
console.log(data); // {items: undefined, loggedIn: true} after write() call
});
state.write('auth.loggedIn', true); //triggers callback
unsubscribe();let state = new EasyDataState();
state.write('auth.loggedIn', false);
let unsubscribe = state.subscribe('auth.loggedIn', (loggedIn) => {
console.log(loggedIn); // [false] and triggered immediately
}, {asArray: true});
unsubscribe();let state = new EasyDataState();
state.write('profile.name', 'admin');
let unsubscribe = state.subscribe('profile.name', (profileName) => {
console.log(profileName); // 'admin' and triggered immediately
// undefined after delete() call
});
state.delete('profile.name'); //triggers callback the second time
unsubscribe();let state = new EasyDataState();
state.write({auth: {loggedIn: true}, name: {first: 'first', last: 'last'}});
let unsubscribe = state.subscribe((data, changedDataAddresses) => {
console.log(changedDataAddresses); // [['auth', 'loggedIn']]
}, {triggerImmediately: false});
state.write('auth.loggedIn', false);
unsubscribe();Using Namespaced Array Addresses
Reading, deleting, or subscribing to multi-level data requires full-address usage,
e.g., state.read(['profile.name', 'profile.permissions']). As in the above
example, sometimes requested entries will have the same ancestor(s). To minimize
addressing redundancies, easy-data-state accepts namespaced array addresses.
For the situations when the last part of several addresses is the same, the
namespaced address should include aliases.
let namespacedAddresses = [['profile.collection', ['name', 'type']], 'info'];
state.write({profile: {collection: {name: 'name', status: true, type: 'type'}}, 'info': 'i'});
state.read(namespacedAddresses); // {name: 'name', type: 'type', info: 'i'}let namespacedAddresses = [['profile', ['name', 'type']], ['auth', [{type: 'authType'}]]];
state.write({profile: {name: 'name', status: true, type: 'type'}}, {auth: {type: 'closed'}});
state.read(namespacedAddresses); // {name: 'name', type: 'type', authType: 'closed'}Integrations with UI Frameworks
React
easy-data-state-react
repository includes bindings to connect an easy-data-state instance to React
components. Usage instructions are provided there.
Other Frameworks
easy-data-state was originally developed as a simpler alternative to Redux,
Recoil, and other data management React-oriented libraries. However, easy-data-state
can be used with other frameworks; contributions of such integrations are welcome.
Development
Development Setup
Perform the following steps to setup the repository locally.
git clone https://github.com/aptivator/easy-data-state.git
cd easy-data-state
npm installTo start development mode run npm run dev or npm run dev:coverage.
Contributing Changes
The general recommendations for contributions are to use the latest JavaScript features, have tests with complete code coverage, and include documentation. The latter may be necessary only if a new feature is added or an existing documented feature is modified.
Performance
Initial performance tests showed easy-data-state executing a 50-level address write
and a respective subscription invocation in about a 100th of a millisecond. More
extensive performance tests will be added in the future.
Caveats
Data state properties/addresses are meant to address plain JavaScript objects and will not work for other structures such as Arrays, Maps, and Sets. The latter can be stored as values.
Callbacks invoked by multiple delete() or write() calls are executed on the same
thread/tick. When run in a context of some framework such as React, this may lead to
concurrent updates to multiple UI components and may result in an error/warning. This is
not a drawback of the library, but a general caveat when working with a framework like React.
Pushing one or some of the delete() or write() operations towards the end of the microtasking
queue usually solves the problem. queueMicrotask() is an optimal method for such deferrals.
Data stored via write() are cloned first. structuredClone() is employed to duplicate
the values. The function is supported only in modern browsers and latest Node versions.
structuredClone() will not copy some objects such as functions.
