Innovation rarely happens in a vacuum. It usually starts with an individual brave enough to contribute an idea and a team inspired enough to make it great. This blog provides a forum for all Centrons to contribute ideas, make suggestions, ask questions and inspire others. There are no boundaries. To participate, all you need is the desire to build great products.

Introducing Transis: A Data Modeling Library for JS

Transis Logo

I’m pleased to publicly announce the release of Transis (github, npm): the open source JavaScript data modeling library we built at Centro to provide the foundation of our product’s front-end model layer.

Transis aims to fill in the gap that frameworks like React and Angular leave with regard to your business data. It provides structure around defining the shape of your model objects, their state, and the relationships between them. It further provides a powerful property observation system that makes it simple to keep your views in sync with your models.

Here is a high-level list of features that Transis provides:

  • Typed attributes
  • Bi-directional associations
  • Nested data loading
  • Model state management
  • Model validations
  • Attribute change tracking with undo functionality
  • Property observation of both simple and computed properties
  • Array observation
  • Computed property caching
  • React integration

We’ll take a quick look at some of the features here, but be sure to review the README for a deeper discussion.

Example

Let’s take a look at how to define a Transis model. Imagine we are building an application to catalog our book collection.

import Transis from 'Transis';

const Author = Transis.Model.extend('Author', function() {
  this.attr('firstName', 'string');
  this.attr('lastName', 'string');
  this.attr('birthday', 'date');

  this.hasMany('books', 'Book', {inverse: 'author'});

  this.prop('fullName', {
    on: ['firstName', 'lastName'],
    get: (firstName, lastName) =>
      Transis.A(firstName, lastName).compact().join(' ').trim()
  });
});

const Book = Transis.Model.extend('Book', function() {
  this.attr('title', 'string');
  this.attr('pages', 'integer');

  this.hasOne('author', 'Author', {inverse: 'books'});
});

Here we’ve defined two model classes: Author and Book. Each has some attrs, which are special typed properties. The attrs define the schema of the data you receive from your backend. Transis ensures that the values are of the correct type, coercing them if necessary. For example, if you set the author’s birthday attribute to the string "1975-03-01", Transis will automatically parse it and turn it into a Date object.

In addition to our model’s attributes, we’ve also defined some associations. The Author model has a has-many association with the Books they’ve authored and the Book has a has-one association to its Author. We’ve also named the inverse on each association—this is what gives us the bi-directional behavior. This means that when you update one side of the association, the other side is automatically kept in sync:

const a = new Author({firstName: 'George', lastName: 'Martin'});
const b1 = new Book({name: 'A Game of Thrones'});
const b2 = new Book({name: 'A Storm of Swords'});

a.books;   // []
b1.author; // undefined
b2.author; // undefined

a.books.push(b1, b2);
a.books;   // [#<Book (NEW):2 {"title":"A Game of Thrones"}>, #<Book (NEW):3 {"title":"A Storm of Swords"}>]
b1.author; // #<Author (NEW):1 {"firstName":"George","lastName":"Martin"}>
b2.author; // #<Author (NEW):1 {"firstName":"George","lastName":"Martin"}>

b2.author = undefined; a.books; // [#<Book (NEW):2 {"title":"A Game of Thrones"}>]
b1.author; // #<Author (NEW):1 {"firstName":"George","lastName":"Martin"}>
b2.author; // undefined

In addition to the attributes and associations, our Author model also has something called a prop defined. A prop can be a simple getter/setter, or it can be a complex calculation of other props (an attr is just a special prop). When defining a computed prop that depends on other props you must declare the dependencies using the on: option.

In our example the fullName prop depends on the firstName and lastName props. When the fullName prop is accessed, Transis automatically gathers the dependencies and passes them into the getter function. So you access your computed prop just like any other JavaScript object property, but under the hood, the getter function that the prop was defined with is called.

const a = new Author({firstName: 'George', lastName: 'Martin'});
a.fullName; // George Martin
a.lastName = 'Washington';
a.fullName; // George Washington

Declaring your dependencies like this may seem like a lot of work to define a simple computed property, but it comes with some benefits. The first of which is that this property can be observed:

const a = new Author({firstName: 'George', lastName: 'Martin'});
a.on('fullName', function() {
  console.log('fullName changed!');
});
a.lastName = 'Washington';
// fullName changed!

The second benefit is that the property can be cached by specifying the cache: true option. This is very useful when you have computed properties that are expensive to compute. When a prop is cached its getter function will only be run once no matter how many times it is accessed. Transis automatically clears the cached value when any of the dependencies change so that it will be re-computed the next time it is accessed.

React Integration

We use React to implement our view layer and Transis was designed to work well with it. There is no dependency on React however—you should be able to use it with any view framework. The property observation in particular is what makes Transis so easy to use with React, because it is dead simple to keep your views in sync with your model.

A React component automatically re-renders when its state or props change, but often you may find yourself needing to trigger a re-render when some deeper manipulation is made to an object passed in via props. If the prop is an instance of a Transis model, then all we need to do is observe the dependent props and call forceUpdate on the component whenever they change. This can easily be done with Transis.ReactPropsMixin:

const AuthorView = React.createClass({
  propTypes: {
    author: React.PropTypes.instanceOf(Author)
  },

  mixins: [
    Transis.ReactPropsMixin({
      author: ['fullName', 'books.size']
    })
  ],

  render() {
    const {author} = this.props;
    return (
      <div>
        {author.fullName} has published {author.books.size} books.
      </div>
    );
  }
});

This component will now re-render when the given author’s firstName or lastName props are changed or the number of books in its books association is changed. Yes, you can observe changes made to associated objects, as well!

Persistence Layer

So far we’ve seen how to instantiate new models, but haven’t reviewed persisting them to permanent storage. Transis is completely agnostic about the persistence mechanism used in your application because it uses the data mapper pattern to communicate with it.

The data mapper pattern is simple—each Transis.Model subclass that needs to be persisted must have its mapper property assigned to an object that responds to one or more of the following methods:

  • query(params)
  • get(id)
  • create(model)
  • update(model)
  • delete(model)

These methods are responsible for doing the actual communication with the persistence layer and are invoked by Transis when you call the appropriate method on the model class or instance (such as Author.get(123) or author.save()).

Since communicating with your persistence layer will likely involve an asynchronous operation, these methods must return a Promise or promise-like object that gets resolved/rejected with the response data when the asynchronous operation is complete. Transis will automatically load the response data into the model instance for you.

There’s More

This post has presented a high-level overview of what Transis is capable of, but there is so much more that wasn’t covered here. Follow the links to learn more about what Transis can do, like: