emce
v0.9.6
Published
Hierarchical immutable model and state system
Downloads
4
Maintainers
Readme
Emce
Emce is an hierarchical immutable model and state system, written in typescript and powered by RxJS.
Note: Emce is in beta the signature is mostly stabilized and will probably be the same for 1.0.0.
Pronunciation: (ĕm′sē′) like in M.C. Hammer
The idea is to have one model to store the application data and state. If you have components that are routed or components that are easily boxed in, you can create an Emce responsible for just a part of the full model. Any consumer of that Emce can be oblivious to the rest of the application. The main model will be updated when that child Emce is updated.
The Emce is comparable to both the controller and the model of a typical MVC. It is responsible to inform of the current state of the model and also to broker updates to the model. You are responsible to handle the updates by implementing an executor responding to actions.
Installation
Install Emce from npm:
npm install emce --save
or
yarn add emce
Creating the Emce
To create the Emce you use the create
function. You supply an executor and an optional initial value for your model. If you do not set an initial value your executor must be able to handle a null value for the model. If you send in undefined
to the initial value your executor will still get null for the model when executing the first action. The type arguments are the model and the action implementation used.
const emce: Emce<Example, ExampleAction> = create(exampleExecutor, {example:'Hello World'});
Getting updates
The Emce will send an update after an action has been executed and a new model has been created by the executor. You can subscribe to the emce to get model updates. You can subscribe to the Emce with multiple subscribers.
emce.subscribe((model: Example) => {
// handle update.
});
The current value of the Emce is also available. It can for example be used to track Emces if they are part of a list so you don't have to subscribe to get the current value.
const v: Example = emce.value;
Actions
An action describes an update to the model. It is used by the executor to create a new state for the model, so they should carry all the information needed to update the model. The implementation of the actions are up to you, they only require a type
property of string type.
Actions are executed by calling next on the Emce.
const action:ExampleAction = emce.next({type: 'EXAMPLE', value:'hello world'});
Next will return the executed action, or something from a middleware.
Note: See emce-child-list for a way of handling async actions by sending observables directly on next.
Dividing the Emce
You can pick out parts of the model and create an Emce for a specific part of the model. This can be useful to let components be oblivious about the application as a whole and only see the part of the model it handles. The Actions executed in a child will be sent to the create method of the parent executor, so that the parent can react to a change in the child. The update will only be sent to the Emce that spawned the child, not to all Emces handling that part of the model. The actions will however be sent all the way up to the first Emce created.
You create a child by specifying an executor and which property of the model you want to watch. You can select sub-properties down to five levels.
const child: Emce<ExampleChild, ExampleAction> = emce.createChild(new ChildExecutor(), 'child');
Alternatively you can specify a translator to get the part of the model that you want.
const child: Emce<ExampleChild, ExampleAction> = emce.createChild(new ChildExecutor(), childTranslator);
If the model you are watching is removed or if the translator returns null
the child Emce will be completed. After it has been completed you will have to create a new one to watch the model again.
Note: If you have an array of models you would like to create Emces for, use the mixin emce-child-list
Disposing
If you are done with a child and don't need it any more you must call its dispose
method.
child.dispose();
Executor (model: T, action: A extends Action) => T`
The executor is responsible to create a new model is response to an action. It should be a pure function that takes a model and an action and returns a new model, or the same object if nothing was changed by the action. The actions should be of the same implementation used when creating the Emce. Make sure you only return a new object if the action actually produced a result.
Note: Since a new object is returned make sure you use an id property on your objects to identify them.
public execute(model: Example, action: ExampleAction): Example {
if (action.type === EXAMPLE_TYPE) {
return Object.assign({}, model, {example: 'example'});
}
return model;
}
Merging executors
If you want to divide the responsibility of updating the model, without creating children, you can use mergeExecutors
to create one executor from several. You can then have one executor responsible for a sub model.
Note: This will only work for plain objects, if you are using a library you need to do this yourself.
To create an executor send in a map object describing which property of the model the executor should handle.
Note: The map object leaves a few things to be desired when it comes to types so you need to verify your self that the executor can handle the sub model.
const exempleExecutor = mergeExecutors({child1: child1Executor, child2: child2Executor});
const emce: Emce<Example, ExampleAction> = create(exampleExecutor, {example:'Hello World'});
All properties of your model needs to get its own executer. If you do not initiate your model on create, it can be cumbersome to handle a null
in every executor. So you can send in an executor for the entire model when merging. It can also be useful if you would like to pull out only a few of the properties to executor. The main executor will execute first and that result will be used by the other executors, so make sure that you do not have any overlapping.
const exempleExecutor = mergeExecutors(rootExecutor, {child1: child1Executor, child2: child2Executor});
const emce: Emce<Example, ExampleAction> = create(exampleExecutor, {example:'Hello World'});
If you want to merge at lower levels of you model, just merge those executors first and then merge the created one.
const child2Executor = mergeExecutors({subchild1: subChild1Executor, subchild2: subChild2Executor});
const exempleExecutor = mergeExecutors({child1: child1Executor, child2: child2Executor});
const emce: Emce<Example, ExampleAction> = create(exampleExecutor, {example:'Hello World'});
Trigger: (model: T, action: A extends Action) => A | null
Trigger gives a parent a chance to react to a change of a child. Trigger is responsible for creating actions based on the action executed on a child or on a child of a child. Having a trigger on the executor is optional.
After an action has executed in a child that action is sent to the trigger method for the parent. Actions created by triggers are executed directly and as a part of the current update. Actions from all children and childrens children are sent to the parent all the way up to the first Emce created.
Trigger should take a model and an action and return an other action. Should return null
for no result.
public trigger(model: Example, action: ExampleAction): ExampleAction | null {
if (action.type === EXAMPLE_TYPE) {
return new ResponseAction(model);
}
return null;
}
In order to use trigger
you need to send in an object that has the executor and trigger when creating the emce.
const emce: Emce<Example, ExampleAction> = create({
executor: exampleExecutor,
trigger: exapleTrigger
}, {example:'Hello World'});
Translator
A translator should be able to get a child model and to be able to return that to the model. The translator can be used to create computed values of your model and serve to a child.
get:(m: T) => U | null
The get function of the translator gets the child model that you are intrested in from the model. If you can't get the child model, return null
.
give:(m: T, mm: U) => T
The give function sets the child model back on the model.
Middleware
Middleware is code that can be added to the process of executing an action. Useful for example for tracing. This has been inspired by redux solution for middleware. Middleware functions are called with by the previous one. The first gets the action supplied to next and the last middleware will supply the action to execute. Any middleware can cancel the action by not calling the following function.
Adding
To add middleware you call withMiddleware
with your middleware or middlewares. You then call create on that. Middlewares is a container for two different middleware one for the normal execution and one for the triggered actions.
const emce: Emce<Example, ExampleAction> = withMiddleware(middleware1, middleware2).create(new ExampleExecutor(), {example:'Hello World'});
A middleware might return a value of another type if that is the case you can create a type.
type MyAction = Action | PromiseAction.
and use that when creating the Emce
const emce: Emce<Example, MyAction> = withMiddleWare(middleware1, middleware2).create(new ExampleExecutor(), {example:'Hello World'});
Problems
Type inference isn't working properly when you are using middleware so you will have to explicitly state your types or cast the result
const emce: MyEmce<Example, ExampleAction> = <Example, ExampleAction, MyEmce<Example, ExampleAction>> withMiddleware...
const emce: MyEmce<Example, ExampleAction> = withMiddleware... as MyEmce<Example, ExampleAction>
Creating
A middleware should be a pure function. There are two types of middleware, one is applied to the regular process of executing an action, which includes executing of the action and any actions created by triggers. The other type is applied to the execution of a triggered action and you are limited in what you can do.
Next
(next: (action: A) => A, value: () => any) => (following: (action: A) => A) => (action: A) => A
This might look a little daunting, but let's break it down.
function middleware(next, value) {
return (following) => {
return (action) => {
log('initial:', value();
log('action: ', action.type);
const result = following(action);
log('new:', value();
return result;
}
}
}
This is creating a middleware that will log out some info about the execution.
function middleware(next, value) {
...
}
The first function is there to give you access to next
and value
on the Emce.
return (following) => {
...
}
The function returned from the first function will supply the function following this middleware. This might be another middleware or the function executing the action and updating the model.
return (action) => {
log('initial:', value();
log('action: ', action.type);
const result = following(action);
log('new:', value();
return result;
}
This is the middleware function that will be called during next. The functions you have available are value
, that returns the current model, and next
, that allows you to send another action for execution. Make sure that you only use value
and next
from within your middleware. To cancel the action just don't call following
. You can change what is returned by the next function by returning any value.
For Trigger
This middleware is similar, but it doesn´t support returning. The value supplied is the transient model that is making its way up the chain and might be changed in a later trigger. You can cancel by not calling following. Canceling will only cancel this action and not any other actions that might be triggered later, by this action or another.
(value: () => any) => (following: (action: A) => void) => (action: A) => void;
function middleware(value) {
return (following) => {
return (action) => {
log('initial:', value();
log('action: ', action.type);
following(action);
log('new:', value();
}
}
}
If you implement a triggermiddleware you need to supply it with a middlewares.
{
next: myMiddleware;
trigger: myTriggerMiddleWare;
}
Mixins
Mixins are used to change, or add to, the functionality of the Emce. Typically you won't have to write one of these yourself.
Adding
Since mixins will alter what is returned from create you need to create a type or interface for the return value from create.
export type MyEmce = MixinInterface1 & MixinInterface2
To add a mixin you call withMixins
with your mixins, you can then call create on that.
const emce: MyEmce<Example, ExampleAction> = withMixins(mixin1, mixin2).create(new ExampleExecutor(), {example:'Hello World'});
If you would like to add middleware as well you just call withMiddleware
before calling create and
const emce: MyEmce<Example, ExampleAction> = withMixins(mixin1, mixin2).withMiddleware(middleware1, middleware2).create(new ExampleExecutor(), {example:'Hello World'});
Problems
Type inference isn't working properly when you are using mixins so you will have to explicitly state your types or cast the result
const emce: MyEmce<Example, ExampleAction> = <Example, ExampleAction, MyEmce<Example, ExampleAction>> withMixins...
const emce: MyEmce<Example, ExampleAction> = withMixins... as MyEmce<Example, ExampleAction>
Unfortunately at the moment the type returned from createChild
isn't correct if you are using middleware you have to explicitly cast the result to your type, or just use any
.
const child: MyEmce<ExampleChild, ExampleAction> = emce.createChild... as MyEmce<ExampleChild, ExampleAction>
Creating
A mixin is a function that creates a class extending Emce, note that this might be another middleware. If you change the signature of methods or if you extend Emce with additional functionallity you should supply an interface that extends Emce. Make sure you still handle the old method signature as best as you can by checking the parameters and sending to super if you can't handle them.
function mixin(emce) {
return class extends emce {
private _lastAction;
public get lastAction() {
return this._lastAction;
}
public next(action) {
this._lastAction = action;
super.next(action);
}
}
}