classy-mst
is the ultimate state management solution for TypeScript and
JavaScript apps: a zero performance penalty wrapper around the amazing
mobx-state-tree and allowing
standard ES6 syntax.
ES6 class methods become "views" or "actions" (when decorated with @action
to indicate they have side effects). Then:
- Changes automatically propagate through views.
- State is protected from modification outside actions.
- State and state diffs (patches) are serializable to JSON and replayable for undo / redo.
- Redux DevTools are supported for working with the state.
- The underlying technology is still MobX.
mobx-state-tree provides the
state management, classy-mst
adds the benefits of standard ES6 syntax:
- Less boilerplate.
this
,super
and inheritance work as you would expect.- No lock-in, easier to switch to other technology if needed.
- Usage
- Inheritance
- Polymorphism
- Getters and setters
- Volatile state
- Asynchronous actions
- Recursive types
- License
Install:
npm install --save mobx mobx-state-tree classy-mst
Use in your code:
import { types } from 'mobx-state-tree';
import { mst, shim, action } from 'classy-mst';
const TodoData = types.model({
title: types.string,
done: false
});
class TodoCode extends shim(TodoData) {
@action
toggle() {
this.done = !this.done;
}
}
const Todo = mst(TodoCode, TodoData, 'Todo');
ES6 methods become views (assumed to have no side-effects) unless decorated
with @action
, which turns them into actions.
Afterwards, Todo
is a regular MST type. Here, TodoData
is an MST type
holding the properties with MobX state tracking magic, and TodoCode
is only
a block of code with methods (views and actions) to use with the type.
The mst
function binds the two together (producing a new type "inheriting"
TodoData
), and the TodoCode
class should not be used directly.
A third, optional parameter gives the resulting model a name.
Names are required for polymorphism to work correctly, when serializing
models to JSON containing fields supporting different possible subclasses.
The shim
function is a tiny wrapper that makes TypeScript accept MST types
as superclasses. It must be used in the extends
clause of the ES6 class
defining the views and actions.
The major differences compared to ordinary ES6 classes are:
this instanceof Class
is false insideClass
, becausethis
refers to a MobX state tree node.- Class properties must be declared using MST type syntax in a separate block before the class.
- MST has no static properties.
You can look at the tests for fully working examples, or run them like this:
git clone https://github.com/charto/classy-mst.git
cd classy-mst
npm install
npm test
You can inherit from and extend other classes wrapped in MST types as follows:
// Inherit Todo and add new count property.
const SpecialTodoData = Todo.props({
count: 0
});
// Original MST type "Todo" containing the wrapped methods
// is needed by shim for binding references to "super".
class SpecialTodoCode extends shim(SpecialTodoData, Todo) {
@action
toggle() {
console.log('Toggled ' + (++this.count) + ' times!');
super.toggle();
}
}
const SpecialTodo = mst(SpecialTodoCode, SpecialTodoData, 'SpecialTodo');
If adding new properties to the superclass, it's important to pass the
unmodified superclass as the second parameter to shim
so that
super
is initialized correctly.
Instances of subclasses can be used in place of their parent classes inside models.
Due to mobx-state-tree
implementation internals, both classes must have been defined
before the first parent class instance has been created anywhere in the program.
Snapshots containing polymorphic types require type names in the serialized JSON,
to identify the correct subclass when applying the snapshot.
A special key $
is automatically added in snapshots when an object in the tree
belongs to a subclass of the class actually defined in the model.
The default key $
for types can be changed by passing a different string to the
setTypeTag
function before creating any model instances. For example:
import { getSnapshot } from 'mobx-state-tree';
import { setTypeTag } from 'classy-mst';
setTypeTag('type');
const Store = types.model({
todos: types.array(Todo)
});
const store = Store.create({
todos: [
SpecialTodo.create({ title: 'Baz' })
]
});
console.log(getSnapshot(store));
The above prints:
{ todos: [ { title: 'Baz', done: false, count: 0, type: 'SpecialTodo' } ] }
Class members with getters become MobX computed properties.
Setters are not considered actions themselves, so they're only allowed to
modify internal state by calling other methods decorated with @action
.
For example:
class TodoCode extends shim(TodoData) {
@action
toggle() {
this.done = !this.done;
}
get pending() {
return(!this.done);
}
set pending(flag: boolean) {
if(this.done == flag) this.toggle();
}
}
You can create a model with volatile state directly using mobx-state-tree
syntax:
const VolatileData = types.model({}).volatile(
(self) => ({ a: 1 })
);
Alternatively, for most types of volatile members (not functions, however) you can define and initialize them inside the ES6 class:
class VolatileCode extends shim(VolatileData) {
b = 2;
}
Asynchronous actions return a promise. The actual method needs to define a
generator, pass it to flow
from mobx-state-tree
, call the returned
function and return its result, like this:
import { types, flow } from 'mobx-state-tree';
import { mst, shim, action } from 'classy-mst';
const AsyncData = types.model({});
class AsyncCode extends shim(AsyncData) {
@action
run() {
function* generate() {
yield Promise.resolve('This gets lost');
return('Returned value');
}
return(flow(generate)());
}
}
const Async = mst(AsyncCode, AsyncData);
Async.create().run().then(
(result) => console.log(result)
);
Fully typed recursive types require some tricky syntax to avoid these TypeScript compiler errors:
error TS2456: Type alias 'Type' circularly references itself.
error TS2502: 'member' is referenced directly or indirectly in its own type annotation.
error TS2506: 'Type' is referenced directly or indirectly in its own base expression.
error TS7022: 'Type' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
Luckily interface types are lazier so they support recursive references. First we can define the type with nice syntax, exactly as it should ideally work:
import { IObservableArray } from 'mobx';
import { types, ISnapshottable, IModelType, IComplexType } from 'mobx-state-tree';
import { mst, shim, action, ModelInterface } from 'classy-mst';
export const NodeData = types.model({
// Non-recursive members go here, for example:
id: ''
});
export class NodeCode extends shim(NodeData) {
// Example method. Note how all members are available and fully typed,
// even if recursively defined.
getChildIDs() {
for(let child of this.children || []) {
if(child.children) child.getChildIDs();
if(child.id) console.log(child.id);
}
}
// Recursive members go here first.
children?: Node[];
}
Then we need unfortunate boilerplate to make the compiler happy:
export const NodeBase = mst(NodeCode, NodeData);
export type NodeBase = typeof NodeBase.Type;
// Interface trickery to avoid compiler errors when defining a recursive type.
export interface NodeObservableArray extends IObservableArray<NodeRecursive> {}
export interface NodeRecursive extends NodeBase {
// Recursive members go here second.
children: NodeObservableArray
}
export type NodeArray = IComplexType<
(typeof NodeBase.SnapshotType & {
// Recursive members go here third.
children: any[]
})[],
NodeObservableArray
>;
export const Node = NodeBase.props({
// Recursive members go here fourth.
children: types.maybe(types.array(types.late((): any => Node)) as NodeArray),
});
export type Node = typeof Node.Type;
Finally, the new type can be used like this:
const tree = Node.create({
children: [
{ children: [ { id: 'TEST' } ] }
]
});
// Both print: TEST
console.log(tree.children![0].children![0].id);
tree.getChildIDs();
Copyright (c) 2017-2018 BusFaster Ltd