Counter

This classic counter example demonstrates the basic usage of modulajs.

With modulajs we use a Model to describe a state machine and a Component to define how we render the model.

A model handles many actions. For each action type, we use a send function to initiate the change, and a recv function to define how an immutable model should respond to an action.

Counter: 0

import { Model } from 'modula';

const ActionTypes = {
  INCREMENT: 'COUNTER_INCREMENT',
  DECREMENT: 'COUNTER_DECREMENT'
};

class CounterModel extends Model {
  static actionTypes = ActionTypes;
  // default props defines the shape of the state
  // which is represented by the model
  static defaultProps = { value: 0 };

  sendIncrement() {
    this.dispatch({ type: ActionTypes.INCREMENT });
  }

  recvIncrement() {
    // a recv function respond to an action which is created in a send function
    // their "type" has to be matched
    return {
      type: ActionTypes.INCREMENT,
      update(model) {
        // model are immutable
        // use set to update a model
        // which returns a new model instance
        // and you're guranteed that the pointer newModel !== model
        const newModel = model.set('value', model.get('value') + 1);

        return [newModel];
      }
    };
  }

  sendDecrement() {
    this.dispatch({ type: ActionTypes.DECREMENT });
  }

  recvDecrement() {
    return {
      type: ActionTypes.DECREMENT,
      update(model) {
        const newModel = model.set('value', model.get('value') - 1);

        return [newModel];
      }
    };
  }
}

export default CounterModel;

Counter List

This counter list example demonstrates how we can build a more complicated model/component reusing existing modulajs models/components.

Counter: 0

Sum: 0

import { Model } from 'modula';
import { map, append, inc, sum } from 'ramda';

// reuse model from previous Counter example
import CounterModel from '../counter/counter_model';

const ActionTypes = {
  COUNTER_ADD: 'COUNTER_LIST_COUNTER_ADD'
};

class CounterListModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = {
    globalId: 0,
    // properties in defaults can be functions so they are only executed when the Model is initialized
    // it is useful when you need to do heavier computation like initialize another Model
    counters: () => [new CounterModel()]
  };

  sendCounterAdd() {
    this.dispatch({ type: ActionTypes.COUNTER_ADD });
  }

  recvCounterAdd() {
    return {
      type: ActionTypes.COUNTER_ADD,
      update(model) {
        // setMulti can update multiple attributes at once
        const newModel = model.setMulti({
          counters: append(
            new CounterModel({ name: `Counter${model.get('globalId') + 1}` })
          ),
          // the value can also be a function
          // which accepts the current value and returns a new value to be set
          globalId: inc
        });

        return [newModel];
      }
    };
  }

  getSum() {
    return sum(map(c => c.get('value'), this.get('counters')));
  }
}

export default CounterListModel;

Default sendUpdate

This example demonstrates how we can utilize the default model sendUpdate function so we don't have to define send/recv functions for actions that only trigger one attribute updates.

test
import { Model } from 'modula';

class Test extends Model {
  static defaultProps = {
    name: 'test'
  };
}

export default Test;

Functional Programming

Do you love functional programming? We do! That's why we have some builtin support for FP in modulajs.

This example demonstrate a more functional way of writing modulajs model code.

Counter: 0

import { Model, withSet, withSetMulti } from 'modula';
import { inc, evolve, dec } from 'ramda';

const ActionTypes = {
  INCREMENT: 'COUNTER_INCREMENT',
  DECREMENT: 'COUNTER_DECREMENT'
};

class CounterModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = { value: 0 };

  sendIncrement() {
    this.dispatch(ActionTypes.INCREMENT);
  }

  recvIncrement() {
    return {
      type: ActionTypes.INCREMENT,
      update: withSet('value', inc)
    };
  }

  sendDecrement() {
    this.dispatch(ActionTypes.DECREMENT);
  }

  recvDecrement() {
    return {
      type: ActionTypes.DECREMENT,
      // equivalent to "withSet('value', dec)"
      // use withSetMulti form for demo purpose
      update: withSetMulti(evolve({ value: dec }))
    };
  }
}

export default CounterModel;

Handling Side Effects

Side effects are additional things that you would like them to happen after updating model itself.

For example you can render a reporting page with mostly empty tables and then kick off additional AJAX loading for each of those tables.

This example demonstrates how we returns additional side effects when doing update for an action.

TODOs
  • TODO Item 1
  • TODO Item 2
  • TODO Item 3
  • TODO Item 4
import { Model } from 'modula';
import { append, inc, remove, length } from 'ramda';

const ActionTypes = {
  DELETE: 'TODOS_DELETE',
  ADD: 'TODOS_ADD'
};

class TodosModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = {
    globalId: 4,
    todos: () => ['TODO Item 1', 'TODO Item 2', 'TODO Item 3', 'TODO Item 4']
  };

  sendDeleteOneByOne() {
    if (length(this.get('todos')) > 0) {
      // Simulate a server request roundtrip delay
      setTimeout(() => {
        this.dispatch({
          type: ActionTypes.DELETE,
          payload: { index: 0 }
        });
      }, 1000);
    }
  }

  recvDeleteOneByOne() {
    return {
      type: ActionTypes.DELETE,
      update(model, action) {
        const { index } = action.payload;
        const newModel = model.set('todos', remove(index, 1));

        // update returns an array
        // the first one is the updated model
        // and all the rest are side effect functions
        // which will be guaranteed to be called
        // but you should not assume they'll be call in strict order
        return [newModel, newModel.sendDeleteOneByOne];
      }
    };
  }

  sendAdd() {
    this.dispatch({ type: ActionTypes.ADD });
  }

  recvAdd() {
    return {
      type: ActionTypes.ADD,
      update(model) {
        const newModel = model.setMulti({
          todos: append(`TODO Item ${model.get('globalId') + 1}`),
          globalId: inc
        });

        return [newModel];
      }
    };
  }
}

export default TodosModel;

Model Context

Since modula models are nested, frequently we see pattern that child models will ask for general informations such as current logged in user, global translation etc. In those scenarios, passing down functions layer by layer is painful.

To address the issue, modulajs Model have a mechanism called Context which can help us access global available functions from a deeply nested child model.

This example demonstrates how to consume context from one of its ancestor model, as well as to build a context provider model.

hello

import { Model } from 'modula';

const GreetingActionTypes = {
  LOCALE_CHANGE: 'HELLO_LOCALE_CHANGE'
};

// context consumer
class GreetingModel extends Model {
  static actionTypes = GreetingActionTypes;

  static defaultProps = {
    currentLocale: 'en_US'
  };

  // implicitly declares dependencies on context methods
  static contextTypes = {
    gettext: 'translate string base on locale'
  };

  sendLocaleChange(locale) {
    this.dispatch({
      type: GreetingActionTypes.LOCALE_CHANGE,
      payload: { locale }
    });
  }

  recvLocaleChange() {
    return {
      type: GreetingActionTypes.LOCALE_CHANGE,
      update(model, action) {
        const { locale } = action.payload;

        return [model.set('currentLocale', locale)];
      }
    };
  }

  sayHello() {
    // calling this.getContext to access context method
    const _ = this.getContext('gettext');

    return _(this.get('currentLocale'), 'hello');
  }
}

// context provider
class GettextModel extends Model {
  static defaultProps = {
    translations: {
      en_US: {
        hello: 'hello'
      },
      zh_CN: {
        hello: '你好'
      },
      ja_JP: {
        hello: 'こんにちは'
      }
    },
    decoratedModel: () => new GreetingModel()
  };

  // declares what context method are available for descandant models
  static childContextTypes = {
    gettext: 'translate string base on locale'
  };

  // returns context methods
  getChildContext() {
    return {
      gettext: (locale, string) => this.get('translations')[locale][string]
    };
  }
}

export default GettextModel;

Model Lifecycle

Modulajs Model has comprehensive lifecycle support. You can define modelDidMount, modelDidUpdate, modelWillUnmount life cycle hooks on model and they will be called given different state change events.

This example demonstrate how we can utilize model life cycle hooks to trigger additional side effects, which is very useful in building very complicated pages.

  • CounterListModel did mount

Sum: 0

import { Model, whenMounted, whenUnmounted, whenUpdated } from 'modula';
import { map, append, inc, sum, tail, empty } from 'ramda';

// reuse model from previous Counter example
import CounterModel from '../counter/counter_model';

const ActionTypes = {
  COUNTER_ADD: 'COUNTER_LIST_COUNTER_ADD',
  COUNTER_REMOVE: 'COUNTER_LIST_COUNTER_REMOVE',
  MESSAGE_ADD: 'COUNTER_LIST_MESSAGE_ADD',
  MESSAGES_CLEAR: 'COUNTER_LIST_MESSAGES_CLEAR'
};

class CounterListModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = {
    globalId: 0,
    counters: [],
    messages: []
  };

  // will be called when the model is attached to the root state tree
  modelDidMount() {
    this.sendMessageAdd('CounterListModel did mount');
  }

  // will be called whenever current model gets updated
  modelDidUpdate(oldModel, newModel) {
    // you can utilize modelDidUpdate to detect deeply nested model changes
    whenMounted(oldModel, newModel, ['counters', 0], counter => {
      this.sendMessageAdd(
        `detected mount: first counter created, initial value = ${counter.get(
          'value'
        )}`
      );
    });

    whenUnmounted(oldModel, newModel, ['counters', 0], counter => {
      this.sendMessageAdd(
        `detected unmount: first counter destroyed, value = ${counter.get(
          'value'
        )}`
      );
    });

    whenUpdated(
      oldModel,
      newModel,
      ['counters', 0],
      (oldCounter, newCounter) => {
        this.sendMessageAdd(
          `detected updated: first counter value updated from ${oldCounter.get(
            'value'
          )} => ${newCounter.get('value')}`
        );
      }
    );
  }

  // will be called when current model is going to detach from root state tree
  modelWillUnmount() {
    this.sendMessageAdd('CounterListModel will unmount');
  }

  sendCounterAdd() {
    this.dispatch({ type: ActionTypes.COUNTER_ADD });
  }

  recvCounterAdd() {
    return {
      type: ActionTypes.COUNTER_ADD,
      update(model) {
        const newModel = model.setMulti({
          counters: append(
            new CounterModel({ name: `Counter${model.get('globalId') + 1}` })
          ),
          globalId: inc
        });

        return [newModel];
      }
    };
  }

  sendCounterRemove() {
    this.dispatch({ type: ActionTypes.COUNTER_REMOVE });
  }

  recvCounterRemove() {
    return {
      type: ActionTypes.COUNTER_REMOVE,
      update(model) {
        return [model.set('counters', tail)];
      }
    };
  }

  sendMessageAdd(message) {
    this.dispatch({
      type: ActionTypes.MESSAGE_ADD,
      payload: { message }
    });
  }

  recvMessageAdd() {
    return {
      type: ActionTypes.MESSAGE_ADD,
      update(model, action) {
        const { message } = action.payload;

        return [model.set('messages', append(message))];
      }
    };
  }

  sendMessagesClear() {
    this.dispatch({ type: ActionTypes.MESSAGES_CLEAR });
  }

  recvMessagesClear() {
    return {
      type: ActionTypes.MESSAGES_CLEAR,
      update(model) {
        return [model.set('messages', empty)];
      }
    };
  }

  getSum() {
    return sum(map(c => c.get('value'), this.get('counters')));
  }
}

export default CounterListModel;

Model Services

Models are immutable. But in some cases, we need to track of something volatile such as the handler of an interval or some other states that isn't suitable to be a part of the model, like cache etc. With modulajs, we use Model Services to support such use cases elegantly.

One pretty common use case for Model Services is some background checks that need to be run in certain intervals, like checking inbox messages.

This example demonstrates how we can create a service and attach it to a modulajs Model, and how we can interact with those volatile state that is maintained in Services.

Loading latest time...

import { Model } from 'modula';

// a service definition is a higher order function
// that when called with service parameters
// should return a createService function
// which will later be called by modula internal
const timeService = function timeService(onReceiveTime) {
  return function createService(getModel) {
    let intervalId = null;
    let count = 0;

    // a service can hook to model lifecycle
    return {
      modelDidMount() {
        intervalId = setInterval(() => {
          const model = getModel(); // get latest model

          count += 1;

          const time = new Date().toUTCString();

          onReceiveTime(model, time);
        }, 1000);
      },

      modelWillUnmount() {
        if (intervalId !== null) {
          clearInterval(intervalId);
        }
      },

      // can be accessed via model.getService('service name').getCount
      getCount() {
        return count;
      }
    };
  };
};

const ActionTypes = {
  TIME_UPDATE: 'SERVICE_MODEL_TIME_UPDATE'
};

class ServiceModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = {
    time: null
  };
  // declare your services to the static class variable services
  static services = {
    time: timeService((model, time) => model.sendTimeUpdate(time))
  };

  sendTimeUpdate(time) {
    this.dispatch({
      type: ActionTypes.TIME_UPDATE,
      payload: { time }
    });
  }

  recvTimeUpdate() {
    return {
      type: ActionTypes.TIME_UPDATE,
      update(model, action) {
        const { time } = action.payload;

        return [model.set('time', time)];
      }
    };
  }
}

export default ServiceModel;

Linked Data

Modula was designed to support a deep hierarchy of components, consequently self-inclusive components are usually designed with a strong data locality assumption (data structures and their corresponding operations around data structures are packed together), which are necessary for reusability.

This design pattern however makes it not as intuitive to share data cross components. In this example we'll utilize a model lifecycle event modelWillUpdate to keep multiple copies of the same data in sync.

In this extreme example, we can update the same 'book' data from 3 modula models, while in reality we can usually identify one data copy as the "golden" copy thus the reconciliation logic could be simpler.

Try click on 'like' buttons on different components and see how the number of likes are automatically synced. And please notice every a few seconds the likes will jump up for 1 which is triggered by a model service in the parent expo model.

2018 Book Detail Design

Title: Man Month Myth

Likes by 150

2019 Book Detail Design
Man Month Myth (150)
import { Model } from 'modula';
import { equals } from 'ramda';
import BookDetailV2018Model from './book_detail_v2018_model';
import BookDetailV2019Model from './book_detail_v2019_model';

const intervalService = function intervalService(interval, onTime) {
  return function createService(getModel) {
    let intervalId = null;

    return {
      modelDidMount() {
        intervalId = setInterval(() => {
          onTime(getModel());
        }, interval);
      },

      modelWillUnmount() {
        if (intervalId !== null) {
          clearInterval(intervalId);
        }
      }
    };
  };
};

const ActionTypes = {
  INIT: 'BOOK_DETAIL_COMPARISION_INIT',
  LIKE_MORE: 'BOOK_DETAIL_COMPARISION_LIKE_MORE'
};

class BookDetailExpoModel extends Model {
  static defaultProps = {
    // there're 3 copies of the "same" data in this example
    //
    // expo model keeps a copy of data
    book: null,
    // 2018 detail model keeps another copy of data
    bookDetailV2018: null,
    // 2019 detail model also keeps a copy of data
    bookDetailV2019: null,
    isLoading: true
  };

  static services = {
    // increase like by 1 every 5 seconds
    automaticLike: intervalService(5000, model => {
      model.sendLikeMore(1);
    })
  };

  modelWillUpdate(oldModel) {
    // reconcile book attributes in different models
    //
    // this case is complicated since all 3 books can have updates
    // meaning there's no single source of truth
    // so we need to handle update differently base on the source of a change

    if (oldModel.get('bookDetailV2018') !== this.get('bookDetailV2018')) {
      const bookFromV2018 = {
        name: this.get('bookDetailV2018').get('title'),
        likes: this.get('bookDetailV2018').get('likes')
      };

      if (equals(this.get('book'), bookFromV2018)) {
        return this;
      } else {
        // take 2018 book value as the primary value
        return this.setMulti({
          book: bookFromV2018,
          bookDetailV2019: origin => {
            if (origin) {
              return origin.setMulti({
                name: bookFromV2018.name,
                likes: bookFromV2018.likes
              });
            } else {
              return origin;
            }
          }
        });
      }
    }

    if (oldModel.get('bookDetailV2019') !== this.get('bookDetailV2019')) {
      const bookFromV2019 = {
        name: this.get('bookDetailV2019').get('name'),
        likes: this.get('bookDetailV2019').get('likes')
      };

      if (equals(this.get('book'), bookFromV2019)) {
        return this;
      } else {
        // take 2019 book value as the primary value
        return this.setMulti({
          book: bookFromV2019,
          bookDetailV2018: origin => {
            if (origin) {
              return origin.setMulti({
                title: bookFromV2019.name,
                likes: bookFromV2019.likes
              });
            } else {
              return origin;
            }
          }
        });
      }
    }

    if (!equals(oldModel.get('book'), this.get('book'))) {
      const bookFromModel = this.get('book');

      // take model book value as the primary value
      return this.setMulti({
        bookDetailV2018: origin => {
          if (origin) {
            return origin.setMulti({
              title: bookFromModel.name,
              likes: bookFromModel.likes
            });
          } else {
            return origin;
          }
        },
        bookDetailV2019: origin => {
          if (origin) {
            return origin.setMulti({
              name: bookFromModel.name,
              likes: bookFromModel.likes
            });
          } else {
            return origin;
          }
        }
      });
    }

    return this;
  }

  modelDidMount() {
    this.sendInit();
  }

  sendInit() {
    this.dispatch({
      type: ActionTypes.INIT,
      payload: {
        book: {
          name: 'Man Month Myth',
          likes: 150
        }
      }
    });
  }

  recvInit() {
    return {
      type: ActionTypes.INIT,
      update: (model, action) => {
        const { book } = action.payload;
        return [
          model.setMulti({
            book,
            bookDetailV2018: new BookDetailV2018Model({
              title: book.name,
              likes: book.likes
            }),
            bookDetailV2019: new BookDetailV2019Model({
              name: book.name,
              likes: book.likes
            }),
            isLoading: false
          })
        ];
      }
    };
  }

  sendLikeMore(likes) {
    this.dispatch({
      type: ActionTypes.LIKE_MORE,
      payload: { likes }
    });
  }

  recvLikeMore() {
    return {
      type: ActionTypes.LIKE_MORE,
      update: (model, action) => {
        const { likes } = action.payload;

        if (model.get('book')) {
          return [
            model.set('book', {
              name: model.get('book').name,
              likes: model.get('book').likes + likes
            })
          ];
        } else {
          return [model];
        }
      }
    };
  }
}

export default BookDetailExpoModel;

Class Mixins

For typical enterprise use cases, we see a lot of similar patterns. Those patterns looks pretty reusable, but still the interrelationships between them are highly chaotic. A better approach is to maintain those states within one single layer other than leaving them in nested child models.

Class mixins are an experimental approach that allows us to reuse certain patterns, while still preserving the flexibility of weaving additional behaviors to the pattern.

This example demonstrates how we can reuse predefined sort and pagination mixin and build a user list on top of them.

ID▼Name
7Joey
6Micheal
5Kevin
4Jack
3Doug
Per Page~ Current Page 1 ~
import { Model } from 'modula';
import { merge, assoc, pipe } from 'ramda';
import query from './user_query';
import { withSort, withPagination, appendSideEffect } from './user_list_mixins';

const ActionTypes = {
  LOAD: 'USER_LIST_LOAD'
};

const ListModel = withSort(withPagination(Model));

class UserListModel extends ListModel {
  // adding additional props to base ListModel
  static defaultProps = pipe(
    assoc('users', []),
    assoc('sort', { by: 'id', order: 'desc' }) // custom default sort
  )(ListModel.defaultProps);
  // adding additional action types to base ListModel
  static actionTypes = merge(ListModel.actionTypes, ActionTypes);

  modelDidMount() {
    this.sendLoad();
  }

  sendLoad() {
    const { page, perPage } = this.get('pagination');
    const { by, order } = this.get('sort');

    const users = query({ page, perPage, by, order });

    this.dispatch({
      type: ActionTypes.LOAD,
      payload: { users }
    });
  }

  recvLoad() {
    return {
      type: ActionTypes.LOAD,
      update(model, action) {
        const { users } = action.payload;

        return [model.set('users', users)];
      }
    };
  }

  // override recvSortBy which was defined in withSort mixin
  // adding additional side effect to default sort by behavior
  recvSortBy() {
    return appendSideEffect(super.recvSortBy, newModel => {
      newModel.sendPageChange(1);
      newModel.sendLoad();
    });
  }

  // override recvPageChange which was defined in withPagination mixin
  // adding additional side effect to default page change behavior
  recvPageChange() {
    return appendSideEffect(super.recvPageChange, newModel => {
      newModel.sendLoad();
    });
  }

  // override recvPerPageChange which was defined in withPagination mixin
  // adding additional side effect to default per page change behavior
  recvPerPageChange() {
    return appendSideEffect(super.recvPerPageChange, newModel => {
      newModel.sendLoad();
    });
  }
}

export default UserListModel;

Hot Zone

By default, modulajs react will re-render everything within a container with a top-down approach, this ensures page is always up to date when model tree gets updated. This is safe but not likely to be the optimal rendering approach, given typical web apps have strong locality zones. For example when filling a form, only the form area needs to be re-rendered.

To allow accurate rendering optimization, modulajs come with a hotZone function, which marks the boundary of the area that has strong locality, and the model updates happening within that "zone" will only trigger the zone updates instead of whole container top-down re-render.

This example demonstrate how we could utilize hotZone to mark the zone that could be rendered locally. To compare the result with previous "Counter List" example, please install React chrome extension and check its "highlight updates" checkbox, and change counter value to see the differences.

Counter: 0

Sum: 0

import { Model } from 'modula';
import { map, append, inc, sum } from 'ramda';

// reuse model from previous Counter example
import CounterModel from '../counter/counter_model';

const ActionTypes = {
  COUNTER_ADD: 'COUNTER_LIST_COUNTER_ADD'
};

class CounterListModel extends Model {
  static actionTypes = ActionTypes;
  static defaultProps = {
    globalId: 0,
    // properties in defaults can be functions so they are only executed when the Model is initialized
    // it is useful when you need to do heavier computation like initialize another Model
    counters: () => [new CounterModel()]
  };

  sendCounterAdd() {
    this.dispatch({ type: ActionTypes.COUNTER_ADD });
  }

  recvCounterAdd() {
    return {
      type: ActionTypes.COUNTER_ADD,
      update(model) {
        // setMulti can update multiple attributes at once
        const newModel = model.setMulti({
          counters: append(
            new CounterModel({ name: `Counter${model.get('globalId') + 1}` })
          ),
          // the value can also be a function
          // which accepts the current value and returns a new value to be set
          globalId: inc
        });

        return [newModel];
      }
    };
  }

  getSum() {
    return sum(map(c => c.get('value'), this.get('counters')));
  }
}

export default CounterListModel;

Create Model Syntax

The alternative legacy way of writing modulajs Model, with createModel helper method from modula-create-model package.

While this will still be supported, the recommended way is to use class syntax given its flexibility to support Class Mixin pattern.

Counter: 0

import createModel from 'modula-create-model';
import createConstants from 'modula-create-constants';

const ActionTypes = createConstants('COUNTER', {
  INCREMENT: 'INCREMENT',
  DECREMENT: 'DECREMENT'
});

const CounterModel = createModel({
  actionTypes: ActionTypes,

  defaults: { value: 0 },

  sendIncrement() {
    this.dispatch({ type: ActionTypes.INCREMENT });
  },

  recvIncrement() {
    return {
      type: ActionTypes.INCREMENT,
      update(model) {
        const newModel = model.set('value', model.get('value') + 1);

        return [newModel];
      }
    };
  },

  sendDecrement() {
    this.dispatch({ type: ActionTypes.DECREMENT });
  },

  recvDecrement() {
    return {
      type: ActionTypes.DECREMENT,
      update(model) {
        const newModel = model.set('value', model.get('value') - 1);

        return [newModel];
      }
    };
  }
});

export default CounterModel;

Overview

The central piece of front end today is about managing state. ModulaJS is created to provide an intuitive and simple way of manage complex state.

Here are some basic state management concepts that you may rarely heard of, but they're the laws ModulaJS is built upon:

  • Application State = Initial State + Deltas, where Delta are triggered by Actions originated from Flux, Elm
  • Application State can be expressed by a Model Tree, where each node of the tree is also a Model representing a valid business entity originated from Redux, Elm
  • Possible Transitions from a given Application State to another could be expressed by a list of Reactions provided by the Model Tree, a successful match of Action and Reaction will evolve the Model Tree to the next state original
  • Side Effect is the result of state transitions, it includes an updated model, as well as zero to many callback functions originated from Elm

Architecture

ModulaJS Architecture

For a certain front end app built with ModulaJS, there should be a single Store at root level, and the Store contains a ModulaJS model tree.

The component tree and ModulaJS model tree can have similar structure, usually each model node in model tree is associated with one or multiple corresponding component nodes in component tree at the corresponding depth, but not all component nodes are associated with a corresponding model node. As a result, ModulaJS model tree is usually simpler than component tree, which implies a certain level of abstraction.

A ModulaJS Model holds the state of current page (or part of the page). A model has a list of attributes, and the attribute value can be an instance of another model, sometimes a list of other model instances, using this simple composition method eventually we are able to represent the whole page in a single model tree.

The model is strictly immutable, which means making any change to the model would create a new version of the model, which has different object pointer comparing to previous one. The most typical calls that can change model are set(key, value) or setMulti({ key1: value1, key2: value2 }), as mentioned earlier, will return a new version of the model.

In typical ModulaJS apps, the state of a page is represent by a single root model. In order to update some node, we always have to go through the "Action -> Dispatch -> Root Model -> locate the handling model -> Return updated handling model -> Return updated root model" cycle. Fortunately, we don't need to worry about those details. Instead we define sender methods to express the intent of change and receiver methods to define the reaction when receiving the change.

There are some more concepts we need to know, like Context, Side Effects, Events etc., for further details refer to the Model API.

Data Flow

ModulaJS Data Flow

Here's how the data flows throughout the system:

  1. Store is initialized with a Root Model.
  2. An user action on child component invokes a specific sender function of its model.
  3. Sender function dispatches an Action which is then sent to the root reducer.
  4. Root Reducer finds a Receiver base on the Action type and where it's dispatched, then delegate to the model who dispatched the action to handle the update, which return a new immutable model instance.
  5. The update bubbles up until root model is also updated (immutability means when child is updated the parent would also be updated).
  6. Root Component is notified and the view rendering process is kicked off given the new root model as the state.

Beyond the main Data Flow, a receiver's update() function can also return Side Effect functions along with new model instance.

Model

Model is the basic unit to encapsulate State, together with some functions to manipulate states. A Model:

  • defines the shape/type of the data (propTypes) and comes with default values (default)
  • defines the shape/type of context data that it provides (childContextTypes and getChildContext()) or depends on (contextTypes)
  • defines bubbling events that it fires (eventTypes) or watches (watchEventTypes and watchEvent())
  • contains pairs of sender and receiver functions that handles data flow logics (sendSomeAction() and recvSomeAction())

This chapter introduced how to use the ModulaJS Model API, and explained some necessary design details. For a complete references of the API, please refer to Model API.

Creating a Model

Extend Model from modulajs package to create a ModulaJS model class.

Props and Values

Assume you need to create a "todo item" model which contains a piece of data like { marked: false, text: 'some text' }. We want to make sure marked is always a boolean and text is always a string. We can use propTypes, which you already got familiar with when learning React. By default, marked is false and text is undefined, we can use defaults to define these.

Once model class is defined, you may create its instances by using new keyword and a props object (its keys/values should match propTypes) as parameter.

import { Model } from 'modulajs';
import PropTypes from 'prop-types';

class TodoModel extends Model {
  static defaultProps = {
    text: undefined,
    marked: false
  };
}

const todo = new TodoModel({ text: 123 }); // Error: text is expected to be a string
const todo = new TodoModel({ text: '123' }); // good

// models come with some default methods like `get`
todo.get('marked') // false
todo.get('text') // '123'

Unlike many other model implementations, in ModulaJS models will refuse to take in "unexpected" data. For example:

const todo = new TodoModel({ text: 'some text', createAt: 1463691281790 });

todo.get('createAt') // undefined

So if you need any additional data, you need to define it in propTypes first.

When defining defaults in Model, please make sure to use functions to define non-primitive defaults values, so those functions only executed when the Model is initialized.

// GOOD
defaults: {
  // `list` is initialized when Model instantiation, so that every new Model
  // instance will have a new instance of `List`.
  list: () => new List(),
  name: "model"
}

// BAD
defaults: {
  // `list` is initialized before Model instantiation, so the value will be
  // shared by all instances of current Model, which may cause unpredictable
  // issues afterward.
  list: new List(),
  name: "model"
}

Display Name

It's recommended to provide a displayName for a Model, which allows ModulaJS to build friendly error messages.

Extra Methods

You can provide some extra getter and setter methods to make the Model easier to use. In the following example, we provide a setter function mark to toggle marked, and a getter function isValid to check if the text is undefined.

Please note that any setter function should always return a new instance, just like set.

import { Model } from 'modulajs';

class TodoModel extends Model {
  static defaultsProps = {
    text: undefined,
    marked: false
  };

  mark() {
    return this.set('marked', !this.get('marked'));
  }

  isValid() {
    return this.get('text') !== undefined;
  }
}

const todo = new TodoModel();
const newTodo = todo.mark();

newTodo.get('marked') // true

Updating the Model and Immutability

A model comes with a set function, with which you can mutate the data in the model. However, models are immutable, so set will always return a new instance, and the old instance remains the same.

const todo = new TodoModel();
const newTodo = todo.set('marked', true);

newTodo.get('marked') // true
todo.get('marked') // false
todo === newTodo // false

There are other APIs to mutate the immutable model:

  • get(key) like Immutable.get.
  • getIn(path) like Immutable.getIn.
  • updateIn(path) like Immutable.updateIn. Returns a new instance of Model.
  • set(key, value) like Immutable.set. value can be a function. Returns a new instance of Model.
  • setMulti({ key: value }) set multiple key-values at once. value can be a function. Returns a new instance of Model.
  • attributes() returns an object with all property-values.
  • toJS() returns an plain object representation of a model, all child model will be converted to plain object as well.

Hierarchy

One model is not enough to express all your data. Naturally you will want to nest your models, just like how you nest up your components. ModulaJS is designed in a way that you can easily manage deep nested models and localize data for every component.

The following example is a TodosModel which contains a list of TodoModel.

import { List } from 'immutable';
import { Model } from 'modulajs';
import ImmutablePropTypes from 'react-immutable-proptypes';
import TodoModel from './todo_model.js';

class TodosModel extends Model {
  static defaults = {
    todos: new List()
  };

  addTodo(text) {
    return this.set('todos', (todos) => {
      return todos.unshift(new TodoModel({ text }));
    });
  }

  markAll() {
    return this.set('todos', (todos) => {
      return todos.map((todo) => {
        return todo.set('marked', true);
      });
    });
  }
}

TodosModel.fromJS = function(definition) {
  const todos = definition.todos || [];

  return new TodosModel({
    todos: new List(map(todos, (todo) => new TodoModel(todo)))
  });
};

In above example code, TodoModel.fromJS(definition) method is not provide in ModulaJS Model API, but it's recommended to add this static method to your model class if the model contains a hierarchy.

In business module or library component development, the model usually forms a model tree.

Context

In ModulaJS model, a Context is a special prop, that is shared with all descendant models. It has a similar design to the Context of React. The context is useful to avoid passing a prop down manually level by level. The following example demonstrates who to define and use context.

class GridModel extends Model {
  static defaultProps = {
    table: () => new TableModel()
  };

  static childContextTypes = {
    someContext: 'context description'
  };

  getChildContext() {
    return {
      someContext: 'some text'
    };
  }
}

class TableModel extends Model {
  static defaultProps = {
    rows: () => new TableRowsModel()
  };
}

class TableRowsModel extends Model {
  // ...

  static contextTypes = {
    someContext: 'context description'
  };

  someMethod() {
    const someContext = this.getContext('someContext'); // 'some text'
  }
}

A context can also be a function (PropTypes.func), so that the descendant model can invoke methods defined in an ancestor model. But please note that, the function-as-context feature is usually used to pass some utility functions, DO NOT abuse it in other use cases. For example, if a descendant model would like to mutate ancestor model, do not pass set() method as context, but leverage Sender and Receiver mechanism that will be introduced in next section.

Method Delegation

Sometimes you define some convenient instance methods in a model, then add the model as a child of a parent model, and you need to "expose" those methods in parent model. It's possible to add a wrapper method:

class GridModel extends Model {
  static defaultProps = {
    table: () => new TableModel()
  };

  getRowIds() {
    return this.get('table').getRowIds();
  }

  getSelectedRowIds() {
    return this.get('table').getSelectedRowIds();
  }
}

Think of the situation that you have many methods to expose. There is a better way to achieve this, which is delegates option:

class GridModel extends Model {
  static defaultProps = {
    table: () => new TableModel()
  };

  static delegates = {
    table: [
      { method: 'getRowIds' },
      { method: 'getSelectedRowIds' }
    ]
  };
}

The delegates way is recommended for most Method Delegation cases.

Model Side Effects

What is Side Effect

According to the Wikipedia:

A function is said to have a side effect if it modifies some state or has an observable interaction with calling functions or the outside world. For example, a particular function might modify a global variable or static variable, modify one of its arguments, ... or call other side-effecting functions.

In UI front end area, a side effect could be more macroscopic. Think of a typical case: a parent component completes loading data, and then its child component begins to load automatically. The child component's loading can be treated as a Side Effect.

Handle Side Effects with ModulaJS

Let's look into the typical case in last section. Considering component modularity, it's definitely not a good idea to write the logic of loading data for the child component inside the parent.

In Model chapter, when explaining Sender and Receiver, we mentioned the return value of a Receiver function contains a update() function, which finally returns an array. The members other than the first one of the array are functions that produce Side Effects. The Receiver should be the single source of triggering side effects in a ModulaJS model.

recvLoadSuccess() {
  return {
    type: ActionTypes.LOAD_SUCCESS,
    update(model, action) {
      const newModel = model.set('data', action.payload);

      return [
        newModel,                  // 1. newModel will be update to store and get rendered
        newModel.sendChildLoad,    // 2. the child component will begin to load automatically
        newModel.bubbleLoadSuccess // 3. the parent will receive a load success event
      ];
    }
  };
}

A side effect could be either one of:

  • Another Sender (either the model's own sender or child model's sender via Method Delegation)
  • A function bubbling event to ancestor models (bubbleEvent(type) calls should align with eventTypes definition)

The former one triggers side effects in current model or descendant models, while the latter one triggers side effects in ancestor models. By combination of the two, you may easily trigger side effects in the entire model tree.

Side Effect Execution Order

The ModulaJS framework ensures side effects are queued at end of browser's event queue via a setTimeout() trick. As a result, the execution order of side effects in the same array is NOT guaranteed. For example, in [newModel, sendSideEffectA, bubbleSideEffectB], sendSideEffectA can depend on updated data in newModel, as newModel has been updated to Store before executing sendSideEffectA; but bubbleSideEffectB can NOT depend on any new data updated by sendSideEffectA (actually updated by corresponding recvSideEffectA), since it's not guaranteed that bubbleSideEffectB would be executed after sendSideEffectA/recvSideEffectA, not to mention sendSideEffectA itself might be an async call. To guarantee this, you may want to write them as chained, as following sample code:

recvActionA() {
  return {
    type: 'ACTION_A',
    update(model, action) {
      const newModel = model.set('dataA', action.payload);

      return [
        newModel,
        newModel.sendSideEffectA
      ];
    }
  }
},

sendSideEffectA() {
  this.dispatch({
    type: 'SIDE_EFFECT_A',
    payload: 'some data'
  });

  // Or this is an async action
  // fetch('some_async_uri')
  //   .then(someSyncData => {
  //     this.dispatch({
  //       type: 'SIDE_EFFECT_A',
  //       payload: someSyncData
  //     });
  //   })
},

recvSideEffectA() {
  return {
    type: 'SIDE_EFFECT_A',
    update(model, action) {
      const newModel = model.set('dataB', action.payload);

      return [
        newModel,
        newModel.bubbleSideEffectB
      ];
    }
  }
}

Another "order" related topic in side effects is the order of Bubble Event watchers. If there're multiple event watchers in ancestor models' path, nearer one will be triggered first (bottom-up). When event bubbling is triggered as a side effect, the bubbling function itself is queued at end of browser's event queue as well, but all its event watchers are triggered (in bottom-up order) at the same event queue slot of bubbling function. Lastly, it's common that an event watcher calls a Sender function.

Model Communications

Model-to-Model Communications

Sometimes a model need to talk to another model. For complicated states, they probably communicate even more often. ModulaJS model provides several mechanisms to handle the different communication directions.

Parent-to-Child

If the other model is a descendant, we could use one of those 2 methods to communicate.

Mutate Child Directly

We could mutate the child directly, which require the child to provider some mutation methods:

class ParentModel extends Model {
  static defaultProps = {
    content: '',
    child: () => new ChildModel()
  };

  sendClear() {
    this.dispatch({ type: ActionTypes.CLEAR });
  }

  recvClear() {
    return {
      type: ActionTypes.CLEAR,
      update(model) {
        const newModel = model.setMulti({
          content: '',
          // the clear method is a mutation method provided by child model
          // which will return a 'cleared' child model
          child: c => c.clear()
        });

        return [ newModel ];
      }
    };
  }
}

The pro for this pattern is that the operation is 'atom'.

Call Child Sender

Or we could use SideEffect + Method Delegation to solve the problem.

class ParentModel extends Model {
  static defaultProps = {
    content: '',
    child: () => new ChildModel()
  };

  static delegates = {
    child: [
      { method: 'sendClear', as: 'sendChildClear' }
    ]
  };

  sendClear() {
    this.dispatch({ type: ActionTypes.CLEAR });
  }

  recvClear() {
    return {
      type: ActionTypes.CLEAR,
      update(model) {
        const newModel = model.set('content', '');

        return [
          newModel,
          // this additional side effect will dispatch another action to clear child
          newModel.sendChildClear
        ];
      }
    };
  }
}

The difference is that call child sender will dispatch another action and will trigger view rendering twice.

Child-to-Parent

modelDidUpdate Life Cycle Hook

There's a special model life cycle hook called modelDidUpdate(oldModel, newModel). An interesting fact of this life cycle hook is that any time when a child model is updated, the parentModel.modelDidUpdate will be called. That means we could utilize this life cycle to identify child changes.

class ParentModel extends Model {
  static defaultProps = {
    otherAttribute: {},
    child: () => new ChildModel()
  };

  modelDidUpdate(oldParentModel, newParentModel) {
    const oldChild = oldParentModel.get('child');
    const newChild = newParentModel.get('child');

    if (oldChild !== newChild) {
      // ok a child update is caught
      if (oldChild.get('name') !== newChild.get('name')) {
        // do something special
      }
    }
  }
}

Sibling-to-Sibling

If two sibling models need to communicate with each other, their common parent needs to become the coordinator.

And then we can use the one of the above "Child-to-Parent" methods to let the parent know that something happens to one model, and the parent model could then use one of the "Parent-to-Child" methods to update the sibling model.

Model-to-View Communication

The model-to-view communication is quite straightforward. Taking React components as an example, a TodoModel instance is passed into TodoComponent as model prop:

const TodoComponent = ({ model }) => (
  <div>{ model.get('todoList').get(0) }</div>
);

TodoComponent.propTypes = {
  model: PropTypes.instanceOf(TodoModel).isRequired
};

View-to-Model Communication: Sender and Receiver

In contrast with above model-to-view communication, the view-to-model communication is relatively complex.

In ModulaJS framework, we introduce the Sender and Receiver pairs in models. A Sender is an instance method with "send" prefix, e.g. sendTodoAdd(); it should always dispatch an Action with specific type and payload by calling this.dispatch(action). A Receiver is an instance method with "recv" prefix, and must be in a pair with corresponding Sender, e.g. recvInit(); it should always return a object: {type: string, update: function(model, action)}, the update() function returns a list of side effects and as a convention the first one must be the new model. An example as follows.

const ActionTypes = createConstants('TODO', {
  ADD: null
});

class TodoModel extends Model {
  static defaultProps = {
    todoList: () => new List()
  };

  sendAdd(todo) {
    this.dispatch({
      type: ActionTypes.ADD,
      payload: { todo }
    });
  }

  recvAdd() {
    return {
      type: ActionTypes.ADD,
      update(model, action) {
        const { todo } = action.payload;
        const newModel = model.set('todoList', list => list.push(todo));

        return [ newModel ];
      }
    };
  }
}

TodoComponent could call model.sendAdd(), then the ADD action is dispatched and then be delegated to the recvAdd.update, which returns a new model. Later TodoComponent gets notified and re-renders with the new model.

Model Lifecycle

Many model instances forms a model tree. The original immutable model tree, let's call it version 1, once be modified will generate a slightly different tree, which can be called version 2. The object pointer are different for version 1 and version 2 model trees.

In such context, There're a few events that could be useful in development:

  • When a new model is created and mounted to a model tree
  • When a existing model is removed and unmounted from a model tree
  • When a existing model is updated inside the model tree

In ModulaJS, we support following life cycle hooks which will be triggered when the above events happened.

  • modelDidMount()
  • modelWillUpdate(sourceModel)
  • modelDidUpdate(oldModel, newModel)
  • modelWillUnmount()

Note Given a model tree, the order of life cycle hook executions are not guaranteed for child models. For example a child model's modelDidMount could be called earlier than its parent, so please do not assume any order here.

Use Case 1

An table model when initialized need to trigger an additional request to fetch table data from server.

class TableModel extends Model {
  modelDidMount() {
    this.sendTableDataLoad();
  }

  sendTableDataLoad() {
    // fetch data asynchronously and dispatch
    return fetch('table.list').
      then({ data } => {
        this.dispatch({
          type: ActionTypes.TABLE_DATA_LOAD,
          payload: { data }
        });
      });
  }

  recvTableDataLoad() {
    return {
      type: ActionTypes.TABLE_DATA_LOAD,
      update(model, action) {
        const { data } = action.payload;

        return [
          model.setMulti({
            isLoading: false,
            data: fromJS(data) // transform mutable data object into immutable map
          })
        ];
      }
    };
  }
}

Use Case 2

A page when entering need to start polling from server for changes. But after leaving the page the polling should stop.

class InboxModel extends Model {
  modelDidMount() {
    this.sendPollingStart();
  }

  modelWillUnmount() {
    this.sendPollingEnd();
  }

  sendPollingStart() {
    // start polling
    // maybe a setInterval
  }

  sendPollingEnd() {
    // stop polling
    // maybe clear the setInterval handler
  }
}

Use Case 3

A parent model need to constantly monitor and reconcile data within its territory.

class ParentModel extends Model {
  modelWillUpdate(oldModel) {
    // keep data in child1 and child2 in sync
    const dataFromChild1 = this.get('child1').get('data');
    const dataFromChild2 = this.get('child2').get('data');

    if (!deepEquals(dataFromChild1, dataFromChild2)) {
      if (oldModel.get('child1') !== this.get('child1')) {
        // use child1's data since update is from there
        return this.set('child2', child2 => child2.set('data', dataFromChild1));
      } else if (oldModel.get('child2') !== this.get('child2')) {
        // use child2's data since update is from there
        return this.set('child2', child2 => child2.set('data', dataFromChild1));
      }
    }

    return this;
  }
}

Model Services

Why do we need model services

As we know that model instances are designed to be immutable, which means every time when something changes, a new model version would be created. When we have everything immutable, you can live with the assumption that "the data we read would never be changed by careless others", which is amazing.

But sometimes the reality is skinny. Much of existing infrastructure were built in mutable way, and in some cases, in order to work with existing infrastructure, you need the ability to deal with mutable data.

One typical example is setInterval, which returns an intervalId, which later could be passed to clearInterval to stop the interval callback from running again. In this scenario, you need to store the intervalId somewhere inside the reachable context for a model instance.

One possible solution is to define a prop named intervalId and dispatch an action to set the value after setInterval. A drawback of this design is that intervalId is an implementation detail, while model prop could be considered as a public interface to users of the model, because they can directly touch the prop value, but for intervalId it would be quite confusing for users to see such a prop. The situation would be even worse in some cases like subscribe function, usually returns a unsubscribe function, which could be called later to stop the subscription. The above solution doesn't make any sense in this scenario, as a violation to our eslint rule, it's ridiculous to set a unsubscribe function prop to a model!

With these examples we realized that having some tools to deal with mutable data would still be necessary, so comes the model services concept.

How to use model services

Let's start from a piece of code which describes a interval service:

import { Model } from 'modulajs';

class ServiceModel extends Model {
  static services = {
    pollMessages: function createService(getModel) {
      // store some state in the closure
      let intervalId = null;
      let pollCounter = 0;

      return {
        // this hook will be called when model is added to the model tree
        modelDidMount() {
          intervalId =
            setInterval(() => {
              fetchMessages().
                then(messages => {
                  const model = getModel();

                  model.sendMessagesUpdate(messages);
                });

              pollCounter = pollCounter + 1;
            }, 1000);
        },

        // this hook will be called when model is removed from the tree
        modelWillUnmount() {
          if (intervalId !== null) {
            clearInterval(intervalId);
          }
        },

        getCounter() {
          return pollCounter;
        }
      };
    };
  };

  sendMessagesUpdate() {
    this.getService('pollMessages').getCounter(); // access service methods
    this.getService('pollMessages').modelDidMount; // life cycle method can also be accessed, it's useful for testing
  }
}

The example above describes a service called pollMessages, which will call setInterval when the model is mounted to the tree and clearInterval with the saved intervalId while the model is unmounted from the tree.

The service definition for pollMessages is a function named as createService, which will be called during model initialization, with a function parameter named getData in this case. The getData function when called returns (the latest version of) the model. The object returns by the createService becomes a service instance, and can be retrieved by calling model.getService('pollMessages').

The service instance can define life cycle hooks that has exactly the same signature of model life cycle hooks, including modelDidMount, modelWillUnmount and modelDidUpdate. ModulaJS will be in charge of calling them at right timing. Similar to getCounter method, those life cycle hooks such as modelDidMount can also be retrieved directly from the service instance, which will help us to test the service itself (only if the service is anonymous, it's recommended to extract the createService function out, and we can easily test that function, we'll explain more in the next 'Reusable model services' section).

const service = model.getService('pollMessages');
const sendMessagesUpdate = sinon.stub(model, 'sendMessagesUpdate');

service.modelDidMount();

const clock = sinon.useFakeTimers();
clock.tick(2999);

expect(sendMessagesUpdate.calledTwice).to.be.true;

The beauty of the example service is that it utilize the closure within the createService to store the intervalId and pollCounter, so the implementation detail is well isolated and could not be accessed directly by model.

Reusable model services

Given that usually a service doesn't need to bound to a specific model, it's recommended to extract the service, test it individually, and even export it as a common stuff to be shared by many models, for example the same pollMessages service as above but reuses a common intervalService:

// interval_service.js
export default function intervalService(interval, callback) {
  return function createService(getModel) {
    let intervalId = null;
    let pollCounter = 0;

    return {
      modelDidMount() {
        intervalId =
          setInterval(() => {
            callback(getModel());
            pollCounter = pollCounter + 1;
          }, 1000);
      },

      modelWillUnmount() {
        if (intervalId !== null) {
          clearInterval(intervalId);
        }
      },

      getCounter() {
        return pollCounter;
      }
    };
  };
}

// model.js
import { intervalService } from 'interval_service';

class ServiceModel extends Model {
  static services = {
    pollMessages: intervalService(1000, (model) => {
      fetchMessages().then(model.sendMessagesUpdate);
    })
  };
}

Deserializability

One interesting fact for a model tree is that it should be possible to be serialized as a big object, and later be deserialized given the same object.

We can imagine that, existing model life cycle hooks should be skipped when doing the deserialization, to prevent some life cycle hooks from loading new data from server and overriding the restored state.

But the life cycle of services are different stories. The service life cycle should always be called even it's deserializing data, or the service cannot function properly (like the interval service).

It's very useful to take this "serializability" and "deserializability" into account when designing a new service, so that we would be more careful and can spend some time making sure the service would still function properly after the deserialization.

Store

Understanding Store

The store has the following responsibilities:

  • Holds application state;
  • Allows access to state via getState();
  • Allows state to be updated via dispatch(action);
  • Registers listeners via subscribe(listener).

API

createStore(
  DecoratedModel,
  [storeEnhancer]
)

Basic Usage

import { createStore } from 'modulajs';
import Model from './model.js';

const store = createStore(Model);

function handleStoreChanged() {
  console.log("Store has changed. The new state is:", store.getState());
}

// subscribe to store changing
store.subscribe(handleStoreChanged);
// unsubscribe
store.unsubscribe(handleStoreChanged);

Advanced: Use Store Enhancer (and middleware)

The third parameter is a store enhancer. You can use compose to merge several enhancers into one.

The following example applies redux-thunk and devToolsExtension to your store.

import { createStore } from 'modulajs';
import { applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk'
import Model from './models';

const store = createStore(
  Model,
  compose(
    applyMiddleware(thunk),
    window.devToolsExtension ? window.devToolsExtension() : f => f
  )
);

Store Enhancer

If an enhancer is provided, the createStore function becomes enhancer(createStore)(DecoratedModel). In this way, enhancer is able to control everything happening in the store: from creating store to handling actions and updating the state.

Middleware

Middlewares are restricted enhancers: they can control state updating and action handling but do not have access to store creating. Middlewares are enough for most use cases.

applyMiddleware can turn middlewares into an enhancer.

ModulaJS borrow the ideas of enhancers and middlewares from Redux, and its interface is designed to be compatible with Redux. So it's safe to use most redux middlewares in ModulaJS projects. You can also read Redux docs: Middlewares to get better understanding of middlewares.

Model API

Create a front end model which holds the state(data structure) of current page(or part of the page). A model has a list of attributes, and an attribute value can be an instance of another model, or even a list of other model instances, in this way we are able to represent the whole page in just one model tree.

A ModulaJS model is strictly immutable, which means making any change to the model would create a new version of the model, which has different object pointer comparing to previous one. The most typical calls that can change model are set(key, value) or setMulti({ key1: value1, key2: value2 }), as mentioned before, will return a new version model.

In typical ModulaJS apps, the state of a page is represent by a single root model. In order to update something, we always need to go through the "Action -> Dispatch -> Root Model -> locate the handling model -> Return updated handling model -> Return updated root model" cycle. Fortunately, we don't have to worry about those too much details. The way we change state is by defining sender/receiver methods, where sender methods taking charge of creating Actions and dispatch then and receiver methods taking charge of defining what action to watch and specify the update callback.

Extend Model

import { Model } from 'modulajs';

class MyModel extends Model (
  static displayName = { ... };
  static defaultProps = { ... };
  static contextTypes = { ... };
  static childContextTypes = { ... };
  static getChildContext = { ... };
  static services = { ... };
  static delegates = { ... };

  modelDidMount() {}
  modelDidUpdate(oldModel, newModel) {}
  modelWillUnmount() {}

  ...senderMethods
  ...receiverMethods
  ...extraMethods
}

Create and return a new Model of the given options.

Parameters

ParamTypeDescription
displayNamestringA display name of the model that is convenient for debugging and logging
defaultPropsobjectDefault values of model props, also describes the shape of the model
contextTypesobjectShape of context data that the model requires/consumes
childContextTypesobjectShape of context data that the model produces; optional
getChildContextfunctionA function returning the value of context data that the model produces, should align with the childContextTypes param; required if childContextTypes specified
servicesobjectservice definitions
delegatesobjectDelegate child models' methods, a special use case is to delegate children's senders
modelDidMountfunctionLife cycle hook, will be called when a model is mounted to the model tree
modelDidUpdatefunctionLife cycle hook, will be called when a model is updated inside the model tree
modelWillUnmountfunctionLife cycle hook, will be called when a model is unmounted from the model tree
...senderMethodsfunctionSenders; functions with "send" prefix, should always dispatch an action by calling this.dispatch(); possible to be async operation, recommended to return the Promise for better testability; optional
...receiverMethodsfunctionReceivers; functions with "recv" prefix, should always return a object: {type: string, update: function(model, action)}, the update() function reduces the model; required if there is a corresponding senderMethods
...extraMethodsfunctionExtra getter and setter methods to make the model easier to use

Returns: a model class that can be instantiated.

Constructor

new ModelClass(props)

import { Model } from 'modulajs';

class ModelClass extends Model {
  ...options
}

new ModelClass(props);

Parameters

ParamTypeDescription
propsobjectModel props, should align with defaultProps, will override default value if a key match the one in defaultProps; optional

Returns: an instance of ModelClass.

Instance Methods

  • get(key) get value for given attribute key.
  • getIn(path) get value for given path for a deep nested attribute, path is the array of keys.
  • updateIn(path) update value for given path for a deep nested attribute, returns a new instance of model.
  • set(key, value) like Immutable.set. value can be a function. Returns a new instance of model.
  • setMulti({ key: value }) set multiple key-values at once. value can be a function. Returns a new instance of model.
  • attributes() returns an object with all property-values.
  • getService(name) returns the specified service instance.
  • getServices() returns all service instances.
  • childModels() returns an array of all children which are instances of model.
  • childModelsRecursive() returns an array of all descendants(children + grandchildren and more) which are instances of Model.
  • toJS() returns an plain object representation of a model, all child model will be converted to plain object as well.

attributes()

Returns: a map with all model attributes

bubbleEvent(eventName)

Parameters

ParamTypeDescription
eventNamestringthe event name

Returns: null

childModels()

Returns: An array of all child models which are descendants of current model

clear()

Returns: a new model has all attributes equals to default values

dispatch(action)

Parameters

ParamTypeDescription
actionobjectan object describes the intention of change

Returns: null

get displayName

Returns: displayName for current model

get(key)

Parameters

ParamTypeDescription
keystringthe key of an attribute

Returns: value of the model attribute

getContext(key)

Parameters

ParamTypeDescription
keystringthe key of an context attribute

Returns: value of the context attribute.

Raises: when context isn't defined in current model contextTypes or cannot find context attribute in parent models, an exception will be raise

getIn(path)

Parameters

ParamTypeDescription
patharrayan array of strings indicating the position of a nested attribute

Returns: the value of the attribute

getService(name)

Parameters

ParamTypeDescription
namestringthe name of the service

Returns: service instance.

getServices()

Returns: an object of service instances, where the key is the service name and value is the service instance.

set(key, value)

alias: 'mutate'

Parameters

ParamTypeDescription
keystringthe key of an attribute
valueanythe new value of an attribute

Returns: a new model with the updated attribute; returns the old model if no attributes are mutated

setMulti({ key1: value1, key2: value2, ... })

Parameters

ParamTypeDescription
keyNstringthe key of an attribute
valueNanythe new value of an attribute

Returns: a new model with the updated attribute; returns the old model if no attributes are mutated

updateIn(path, valueOrMapFunc)

Parameters

ParamTypeDescription
patharrayan array of strings indicating the position of a nested attribute
valueOrMapFuncfunctionthe new value of the attribute or the callback function which will be provided the current value of attribute and return an updated value

Returns: a new model with the updated attribute; returns the old model if no attributes are mutated

toJS()

Returns: a map which contains all attributes and the value of each attributes are also toJS()ed

Test Util API

ModulaJS ship with a few helper functions to make testing easy.

givenContext({ key: value, ...}, model)

returns a wrapped model which can access those provided context key/values.

import { TestUtil } from 'modula-test-util';

const model =
  TestUtil.givenContext({
    {
      value1: 'a',
      gettext: a => a
    },
    new Model()
  });

model.getContext('value1') === 'a';
model.getContext('gettext') == a => a;

processAction(model, action)

Apply the action to model, and returns an array of side effects. The first item in side effects is the newModel while the others are other side effect functions. The last function would be a special side effect function for triggering life cycle hooks.

import { Model } from 'modulajs';
import { TestUtil } from 'modula-test-util';

const ActionTypes = {
  CHANGE: 'MY_MODEL_CHANGE'
};

class MyModel extends Model {
  modelDidUpdate(oldModel, newModel) {
    this.sendZ(oldModel, newModel);
  }

  recvChange() {
    return {
      type: ActionTypes.CHANGE,
      update(model) {
        const newModel = model.set('a', 'b');

        return [
          newModel,
          newModel.sendX,
          newModel.sendY
        ];
      }
    }
  }
}

const model = new MyModel();
const action = { type: ActionTypes.CHANGE };

const [ newModel, sideEffect1, sideEffect2, lifeCycleSideEffect ] =
  TestUtil.processAction(model, action)

newModel == model.set('a', 'b');
sideEffect1 === newModel.sendX;
sideEffect2 === newModel.sendY;

// lifeCycleSideEffect once been called will return a Promise object
// which we can then chain with a thenable block to make assertions
// make sure we returned the Promise so mocha knows we're running some async tests
return lifeCycleSideEffect().
  then(() => {
    // modelDidUpdate will be triggered
    // sendZ will be called here with model and newModel respectively
  });

createAction(model, action)

return a different version action with internal marks. Usually it's worked together with sender tests, where we assert that dispatch function is called with an action

import { TestUtil } from 'modula-test-util';
import sinon from 'sinon';

const dispatch = sinon.spy();

const model =
  TestUtil.givenContext(
    { dispatch },
    new Model()
  );

const action = {
  type: 'SOME_TYPE',
  payload: {
    name: 'abc', id: 123
  }
};

const internalAction = TestUtil.createAction(model, action);

model.sendSomeType();

expect(dispatch.calledWith(internalAction)).to.be.true;