state-manager-direct

1.3.15 • Public • Published

StatelyJS

StatelyJs is a state manager with two goals: reduce boilerplate and increase scaleability and freedom. Technically that's three. It offers the dependability of a single-file dispatcher like Redux, but with efficient, surgical state-management like Flux. Here's the simplest use of Stately:

import {StateObject} from 'state-manager-direct'
var state = new StateObject()

state.registerAddress('greeting',{person:'Jim'})
state.addListener('greeting',()=>{
  alert('Hello '+state.getState('greeting.person'))
})
state.dispatch(address:'greeting.person',state:'Kelly')
//alerts('Hello Kelly')

And that's it! 4 or so lines of code to get started. That's boiling down boilerplate!. Don't worry, Stately has reducers (called managers), and accepts action-based dispatches.

Now about that freedom and scaleability. Did you notice that in the above example we're specifying an address to listen to? With Stately, listeners are only called when their addresses or child-addresses are modified.

Ex.
state.registerAddress('greeting',{
	person:'jim',
	job:{
		title:'manager',
		years:5,
		duties:{
			required:[thing1,thing2],
			optional:[thing1,thing2]
		}
	},
})
state.addListener('greeting.person',()=>{do stuff})
state.addListener('greeting.job',()=>{do stuff})
state.addListener('greeting.job.title',()=>{do stuff})
state.addListener('greeting.job.duties',()=>{do stuff})

state.dispatch({address:'greeting.job.title',state:'president'})
//triggers listeners at greeting.job.title and greeting.job only

This melding of Redux with sub-pub architecture allows for more organic calls to the dispatcher, even to the point of executing dispatch calls to a certain address after every frame of simultaneous animations while avoiding cpu intensive listener triggers elsewhere on the state manager. All the state variables are centralized in one object, and have one single-file dispatcher to over see them.

Each listener is called only once on the same dispatch. So you can freely attach your listener to many places on the state tree.

Building State

There are several ways to initialize a state tree. Ex. 1

var tree= {props and vals and managers}
var state=new stateObject({state:tree})

Ex. 2

var tree= {props and vals, maybe some managers}
var state=new stateObject()
state.expand({state:tree})

Note: state.expand in this example can start from a location already on the tree

Ex. 2

var tree= {props and vals, maybe some managers}
var secondTree= {props and vals, maybe some managers}

var state=new stateObject()
state.expand({state:tree})
state.expand({state:secondTree,startFrom:'path.to.existing.address'})

The above will replace the existing tree/value at the specified address with a new expanded tree.

Reducers/Managers

What Redux calls reducers, Stately calls managers, because they manage a particular state address. To create a state tree with managers, create an object and hang your manager functions anywhere on the tree you want them to manage (do not call them).

So for example,

var job=function(payload){
	if(this.__init){//initializes state, only called once
		this.value='some value'
		return
		//this.__init is automatically set to false
	}
	//manage stuff
	//return true if modified, false if not.
	return true
}
var myStateTree={
	greeting:{person:'jim',job}
}
var state=new StateObject({state:myStateTree})

Upon instantiation of the StateObject, Stately will propagate through the state tree. Where it finds a function, it will store the function, and put in it's place on the tree a new object having a property __init set to true. It will then bind the function to the newly created object. The function is then run once inside the new object space to initialize it. With __init set to true, the __init code is executed on the first run. __init is then set to false.

Remember, for any manager hung on the state tree, 'this' refers to the object that lives where the function was originally found on the tree, after instantiation.

Note, the state tree that StatelyJs operates on is cloned from the original state tree submitted.

It is highly recommended to include the __init code, even if just to block the manager from managing on it's initializing call. __init code format is simply:

if(this.__init){
  //configure this
  return
}

If in the course of initializing, the manager function sets a property pointing to another function, Stately will propagate through that function in the same way, and the next and so on. To block function propagation and keep a method on the managed space, include a '__keepMethods' property in the __init code like this

if(this.__init){
	this.method=function(){do stuff}
	this.__keepMethods={method:1}
	return
}
switch(payload.action){
	case 'desiredAction'
		this.method()//will do stuff
		break
}

Before initializing, each object space is created with the following properties:

	__init:true,
	__pathTo:'path.to.this.address',
	__actions:{},//the actions this manager subscribes to  
	__blockDirect:false,//determines whether the properties found at this can be set explicitly with a known value
	__keepMethods:{}

The __actions object holds all the actions that will activate the manager. For instance, if we defined a 'changeJob' action, then we can subscribe the manager to that action by including a truthy changeJob property in it's __actions object:

var job=function(payload){
	if(this.__init){//initializes state, only called once
		Object.assign(this,initialValue)
		this.__actions.changeJob=1
		return
	}
	this.title=payload.data.title		
	//return true if modified, falsey if not.
	return true
}
var state=new StateObject({job})

Then if we call

state.dispatch({
	action:'changeJob',
	data:{title:'CEO'}
})

Our manager will change it's title property from salesman to CEO, and its listeners and parent listeners will be triggered. Only managers subscribed to the changeJob action will be called.

Important:

A manager must return truthy in order to trigger it's listeners. To add sub addresses on which to trigger listeners, return a subaddress string or array of subaddress strings to be appended to the manager address. For instance if you trigger a manager at 'path.to.address', adding

return ['sub1','sub2']

to your manager's return value will cause activation of listeners at 'path.to.address.sub1','path.to.address.sub2' in addition to 'path.to.address'

Dispatching

Note: the state tree does not need managers. Some, none, or all of the addresses on the tree can have managers. State can be set with a direct value or by calling a manager responsible for that portion of the state, if one exists.

A dispatch payload has the following possible properties:

{
  address:string pointing to the state tree address,
  state:value to set at the address specified above, This value can be anything except undefined. To set undefined at a state address, submit the string, 'undefined'  on the state property of the payload
  action:string
  data:data value for managers subscribing to the action to use to reconfigure their slice of the state tree
  man1:if truthy, and address is specified, only the manager at the address specified will be activated 
  direction: {string} 'up' | 'down' | 'both', 'target' .  Specifies whether downline or upline listeners should be called. A value of 'target' will cause only the listeners AT the dispatch address to be called. Overrides the default setting which is 'up'
}

To set an address directly with a known value include an address and state property on the payload. If the manager responsible for that address has blockDirect set to false, or there is no manager at the address, the address will be set directly with the state value. (false is default for blockDirect).

state.dispatch({
  address:'greeting.person',
  state:'Andy'
})

If state and address are present, this behavior will suppress action-based dispatches.

To submit an action-based dispatch, include a data property and action property.

state.dispatch({
  action:'sayHello',
  data:{to:'Dominique'}
})

To call a manager at an address, and all the managers below it, include address, action (if necessary) and data (if necessary) but omit state:

state.dispatch({
  address:'greeting.job'
  action:'sayHello',
  data:{to:'Dominique'}
})

Within a dispatch, "state" is meant for setting directly, 'data' is intented for managers.

To call a single manager at an address, (no children) include a truthy man1 property on a payload like the one above.

Publish Dispatches

To call listeners on an address without updating the tree, call

state.dispatch({address:'path.to.address'})

To call all listeners, use

state.dispatch({address:true})

Note: the 'dispatch' method accepts arrays of dispatch payloads of the above form. Listeners are aggregated and called after all the dispatch payloads have been executed (once per listener).

Saving and Hydrating

Calling

 var savedState=state.packState() 

will return a JSON of the current state.

To hydrate a saved state tree, use

var state=new StateObject()
var savedState=await getFromServer() //-->fetches json previously packed by stately
state.unpackState({savedState:savedState})

The above will save the state tree AND managers. For this reason, It is highly recommended that your managers be pure functions. If you want to create managers that are not pure, then save state this way:

var savedState=state.packState({managers:false})

and to revive,

state.expand({state:InitializationTreeWithNonPureManagers})
state.unpackState({savedState:savedState})

Stately uses Dupify to serialize state. If you need completely faithful serialization, be sure to use data structures of the type supported by Dupify. (npm deep-clone-and-serialize)

For the purposes of tracking and traversing the statetree, stately itself can only penetrate standard objects. (possibly arrays, but that's untested)

Methods

registerAddress( address, value, overwrite)

registers an address on the state tree under the name submitted. Multi-node addresses are allowed (of the form 'path.to.address'). Each node in the path will be created if it does not exist. Set overwrite to true to overwrite the state found in the path of the address submitted. Does not trigger listeners.

PARAMS

  • {string} address -> address to set the value. Ex 'path.to.address'
  • {*} value -> the value to set at that address

expand({param package})

Works very much like registerAddress, but where register address simply appends the tree submitted to the existing state tree, 'expand' recursively expands managers found on the tree submitted. Also, expand creates a clone of the submitted object, and does not preserve circular references. Here again, 'registerAddress' simply tacks on the submitted object.

PARAMS

  • state : the new state to append onto the state model and expand
  • startFrom : the address on the state to place the new state (optional)
  • tunnel : true||false||'overwrite' (default:true)(optional, applicable only if the startFrom address does not exist already) If tunnel is set to false, It will not forge a new node path to the startfrom address, if it does not exist. If set to true, it Will forge a path the the 'startFrom' address,but will not overwrite any primitives found in the path. Set tunnel to 'overwrite' to tunnel a path to the 'startFrom' address, overwriting any values found in the path.

addListener(addresses, actions, missingAddress, bookKeep, listenDirection)

Params

  • {string|array} addresses,
  • {func|array} actions,
  • {string} missingAddress = 'allowAnyway', --> allows addition of addresses that do not exist on the state. "Publish Dispatches" to these addresses are legal. Also, listeners may be set at an address before the state is set at that address.
  • {bool} bookKeep = true, --> stately destroys and regrows a listener tree after every listener addition. For the sake of performance, if you have many listeners, set this to false, and then call statelyInstance.growListenerTree()
  • listenDirection {string} = 'above'|'below'|'both' --> default: 'both'. Determines in what direction the listener at that address will listen for dispatches.

Also takes a parameter object with the above properties

addListener takes an address or array of addresses and a function or array of functions and cross-links them. All the functions submitted will be called after modification of the downlines of any of the addresses submitted.

listenDirection Ex1. 'below' and 'above' are inclusive of the address being dispatched to. (think 'below or at', and 'above or at')

a listener is placed on a tree at address 'one.two.three.four', with listenDirection set to 'above'. A dispatch to address 'one.two' with a direction of 'down' will trigger the listener. However, a dispatch to 'one.two.three.four.five' with a direction of 'up' will not trigger the listener because it is only listening for dispatches that happen above it.

listenDirection Ex2.

A single listener function is placed at two addresses on the state tree :'one.two' with a listenDirection of 'above' and 'one.two.three.four' with a listenDirection of 'below'. A dispatch to 'one.two.three' will never trigger the listener because it is listening for dispatches above (or at) two, and below (or at) four.

addListenerBatch(batch, missingAddress )

This function is useful when you have many listeners to add to the tree. It will wait until all the listeners have been added to grow the internal listener tree. PARAMS

  • {2-dimensional array} see below example
  • {string} missingAddress -->whether to place a listener at the address even if there is no state underneath. Values are 'omit' and 'allowAnyway'. Default is 'allowAnyway'

batch should be a 2-dimensional array. In the second level of the batch array, the 0th index is an address or array of addresses, and the first index is the listener or array of listeners

Ex. stateInstance.addListenerBatch([
	['one', func1],
	[ ['one.two','a.b.c'] , [anotherFunc, oneMoreFunc] ]
])

would be the equivalent of 

stateInstance.addListener({
	addresses: 'one',
	actions: func1,
	bookKeep = false		
}
stateInstance.addListener({
	addresses: ['one.two', 'a.b.c']
	actions: [ anotherFunc, oneMoreFunc]
	bookKeep = false		
}
stateInstance.growListenerTree()

Note: Listeners need not have actual state under them. A listener can be set to an address that doesn't exist on the state tree. Methods are coming for cleaning 'dangling listeners'

setLoopCutoff(number) PARAMS

  • {number} number -> the number of appearances any one function can make on a single dispatch. Default is infinity

If there are any infinite loops happening in your state-setting architecture, Stately can interrupt them after a certain stack size. Call this function with the maximum number of times ANY ONE listener should appear on the stack before the loop is shut down. Default is Infinity. Returns the current loop cutoff. Note this only limits the number of times any single function can apear on the stack. Note: Loops are inhibited by skipping listeners, not stopping the entire batch action. This means that all appropriate listeners are called at least once on each dispatch.

removeListener(addresses, actions)) PARAMS

  • {string|array} addresses -> address or array of addresses from which to remove the listeners
  • {function|array} actions -> function or array of functions to remove

removeListener takes an address or array of addresses and a function or array of functions and cross-unlinks them. All the listener functions submitted will be removed from all the addresses submitted.

dispatch(object payload )

dispatches actions or sets state directly and triggers listeners for any state addresses or sub-addresses affected on the dispatch action. See subsection 'Dispatches" above for payload structure and behavior. Accepts arrays of payloads. Listeners are called after all the payloads have been handled. (once per listener)

getState({string} address [optional])

returns the part of the state tree specified by the address argument. If no address is supplied, getState returns the entire tree. Returns @@TERMINATED_SEARCH if an address does not exist, or undefined if the address exists but has not been set.

Using stateInstance.getState().further.navigation should be faster than stateInstance.getState('further.navigation')

setDefaultListenDirection(string)

Sets the default direction that listeners should 'point their ears'. Values are 'above', 'below', or 'both' for both directions. Default is 'both'-meaning listeners respond to state changes above and below them on the state tree, if the dispatch 'shouts' in their direction.

setDefaultDispatchDirection(string)

Sets the default direction that dispatches should 'shout'. Values are 'up', 'down', 'target' or 'both', for both directions(includes target). Default is 'up' meaning that dispatches only shout toward listeners placed above them on the state tree.

unpackState({savedState})

de-serializes a JSON of previously saved state and sets the state tree . (existing managers are re-attached, if not included in the saved state)

packState({managers:true||false})

returns a JSON of the current state tree. includes managers unless the 'managers' param is set to false.

Note: At present setting state values directly is not allowed if there are managers in the downline of the address being set. This applies only to properties with downlines, not primitives housed inside object spaces with downlines. For instance, in the following example,

var tree = {
	propertypointer: function(){
		if(this.__init){
			this.blockDirect = false // default
			this.internalProp = {
				foo:'bar'
			}
		}
	}	
}

var state1 = new StateObject({state: tree1})

state.dispatch({address:'propertypointer',state:'someOtherValue'}) state.dispatch({address:'propertypointer.internalProp',state:'someOtherValue'})

The first dispatch will not be successful because the propertypointer pointer has a manager in it's downline, where the second dispatch will be successful because the manager has allowed direct setting of values within it's 'space', AND there are no other managers in the downline of the property being set.

Package Sidebar

Install

npm i state-manager-direct

Weekly Downloads

1

Version

1.3.15

License

MIT

Unpacked Size

61.7 kB

Total Files

6

Last publish

Collaborators

  • conleymon