Using Transformations

Tracking Mutable State using Immutable, Shared Data Structures

/*
    The store that holds our domain: boxes and arrows
*/
const store = observable({
    boxes: [],
    arrows: [],
    selection: null
});

/**
    Serialize store to json upon each change and push it onto the states list
*/
const states = [];

autorun(() => {
    states.push(serializeState(store));
});

const serializeState = createTransformer(store => ({
    boxes: store.boxes.map(serializeBox),
    arrows: store.arrows.map(serializeArrow),
    selection: store.selection ? store.selection.id : null
}));

const serializeBox = createTransformer(box => ({...box}));

const serializeArrow = createTransformer(arrow => ({
    id: arrow.id,
    to: arrow.to.id,
    from: arrow.from.id
}));

In this example, the state is serialized by composing three different transformation functions. The autorunner triggers the serialization of the store object, which in turn serializes all boxes and arrows.

  1. The first time box is passed by map to serializeBox, the serializeBox transformation is executed and an entry containing box and its serialized representation is added to the internal memoization table of serializeBox.
  2. Imagine that another box is added to the store.boxes list. This would cause the serializeState function to re-compute, resulting in a complete remapping of all the boxes. However, all the invocations of serializeBox will now return their old values from the memoization tables since their transformation functions didn't (need to) run again.
  3. Secondly, if somebody changes a property of box, this will cause the application of the serializeBox to box to re-compute, just like another other reactive function in MobX. Since the transformation will now produce a new JSON object based on box, all observers of that specific transformation will be forced to run again as well. That's the serializeState transformation in this case. serializeState will now produce a new value in turn and map all the boxes again. But except for box, all other boxes will be returned from the memoization table.
  4. Finally, if box is removed from store.boxes, serializeState will compute again. But since it will no longer be using the application of serializeBox to box, that reactive function will go back to non-reactive mode. This signals the memoization table that the entry can be removed so that it is ready for GC.

So, effectively, we have achieved state tracking using immutable, shared data structures here. All boxes and arrows are mapped and reduced into single state tree. Each change will result in a new entry in the states array, but the different entries will share almost all of their box and arrow representations.

Transforming a Data Graph Into Another Reactive Data Graph

Instead of returning plain values from a transformation function, it is also possible to return observable objects. This can be used to transform an observable data graph into another observable data graph, which can be used to transform... you get the idea.

Here is a small example that encodes a reactive file explorer that will update its representation upon each change. Data graphs that are built this way will in general react a lot faster and will consist of much more straight-forward code, compared to a derived data graph that is updated using your own code.

Unlike the previous example, the transformFolder will only run once as long as a folder remains visible; the DisplayFolder objects track the associated Folder objects themselves.

In the following example, all mutations to the state graph will be processed automatically.

  • Changing the name of a folder will update its own path property and the path property of all its descendants.
  • Collapsing a folder will remove all descendant DisplayFolders from the tree.
  • Expanding a folder will restore them again.
  • Setting a search filter will remove all nodes that do not match the filter, unless they have a descendant that matches the filter.
  • Etc.
var m = require('mobx')

function Folder(parent, name) {
    this.parent = parent;
    m.extendObservable(this, {
        name: name,
        children: m.observable.shallow([]),
    });
}

function DisplayFolder(folder, state) {
    this.state = state;
    this.folder = folder;
    m.extendObservable(this, {
        collapsed: false,
        get name() {
            return this.folder.name;
        },
        get isVisible() {
            return !this.state.filter || this.name.indexOf(this.state.filter) !== -1 || this.children.some(child => child.isVisible);
        },
        get children() {
            if (this.collapsed)
                return [];
            return this.folder.children.map(transformFolder).filter(function(child) {
                return child.isVisible;
            })
        },
        get path() {
            return this.folder.parent === null ? this.name : transformFolder(this.folder.parent).path + "/" + this.name;
        })
    });
}

var state = m.observable({
    root: new Folder(null, "root"),
    filter: null,
    displayRoot: null
});

var transformFolder = m.createTransformer(function (folder) {
    return new DisplayFolder(folder, state);
});


// returns list of strings per folder
var stringTransformer = m.createTransformer(function (displayFolder) {
    var path = displayFolder.path;
    return path + "\n" +
        displayFolder.children.filter(function(child) {
            return child.isVisible;
        }).map(stringTransformer).join('');
});

function createFolders(parent, recursion) {
    if (recursion === 0)
        return;
    for (var i = 0; i < 3; i++) {
        var folder = new Folder(parent, i + '');
        parent.children.push(folder);
        createFolders(folder, recursion - 1);
    }
}

createFolders(state.root, 2); // 3^2

m.autorun(function() {
    state.displayRoot = transformFolder(state.root);
    state.text = stringTransformer(state.displayRoot)
    console.log(state.text)
});

state.root.name = 'wow'; // change folder name
state.displayRoot.children[1].collapsed = true; // collapse folder
state.filter = "2"; // search
state.filter = null; // unsearch

results matching ""

    No results matching ""