Model-View-Intent with React and RxJS

Mar 05, 2016


If you are using Facebook's React, you would have come across Flux, an unidirectional data flow architecture that facebook suggests to use with React. Unfortunately, the Flux overview is light on some details and even after looking at the tutorial code, I still found Flux to be somewhat complicated for what it wants to achieve. So I was looking for Flux alternatives and I came across this excellent post on how to implement the ubiquitous Model-View-Controller pattern in a reactive way. Using that I was able to build a simple example with unidirectional data flow. I also used a REST API as opposed to using a JavaScript object as a data store.

The rest of this post describes how the example was implemented. If you'd rather just skip all that and just look at the code, it is available here.

What the example application does

Our example application is oversimplified, but I think it illustrates how to use React in a realistic fashion. Most Flux examples I've come across just use a JavaScript object as a data store, whereas in typical web applications, the actual data store is elsewhere and you make ajax calls to interface to it. Typical operations are — read data, delete/update data and add new data. Accordingly, our example application will display a list of users, allow us to delete users from that list and to create new users.

An overview of the implementation

Our example application has the following JavaScript modules:

  1. UserView — A React component which is our View module. It displays a list of users and also allows us to delete/create users.
  2. UserStore — A data store which is the Model. It talks to a REST API to get a list of users, to delete a user and to create a user.
  3. UserIntent — The Intent object which conveys actions initiated in the view to the model.

Here's how the objects interact:

  1. The view object puts out notifications — like it wants to get a list of users or that it wants to delete an user or that it wants to create an user. The intent object listens on these notifications.
  2. On receiving a notification, the intent object processes it and sends out notifications of its own. The model object listens on these notifications.
  3. The model object, on receiving a notification from the intent object, talks to a REST API to either get a list of users, delete an user or create an user. After performing the data operation, the model object will put out the list of users.
  4. The view object listens for the data put out by the model object. It then displays this data.

So we have this effective flow of events — from view to intent to model to view:

MVI data flow

That gives us an unidirectional data flow as recommended by Facebook's Flux architecture, but without the complexity of Flux.

The implementation

I recommend that you keep the source code open and refer to it when following the implementation details explained below.

Our example uses RxJS to send out notifications and listen in on them. RxJS is the JavaScript implementation of the ReactiveX API. If you haven't worked with it before, read this introduction. Then read this RxJS introduction. Don't worry too much about fully understanding those introductions. As far as our example is concerned, we are only interested in creating a stream, putting data in it and listening in on the stream. This code snippet shows all the RxJS features that we will be using:

//Create a stream object
var stream = new Rx.Subject();

// Add a subscriber on the stream
var subscriber = stream.subscribe(function(element) {
  console.log("Received: " + element);
})

// Emit a value into the stream
stream.onNext("1");

// The console will output: "Received: 1"

The stream is where we put out notifications. In ReactiveX parlance putting out notifications is called as emitting a value/item/object to the stream. This emitting is done by calling onNext() on the stream. To listen to the values emitted by a stream, we need to subscribe to the stream. Calling subscribe() on a stream creates a subscriber.

Linking together the Model, View and Intent objects

As described above, Intent observes the View, Model observers Intent and View observes Model. The file src/app.jsx is where we make that happen:

var UserView = require("./components/UserView.jsx");
var UserStore = require("./stores/UserStore.js");
var UserIntent = require("./intents/UserIntent.js");

UserIntent.observe(UserView);
UserStore.observe(UserIntent);

ReactDOM.render(<UserView store={UserStore}/>, document.getElementById("content"));

Note that we didn't say UserView.observe(UserModel). Instead we are using React's props object to pass our UserStore object to the view. We need to do that because React manages the component's state and lifecycle and not us. As we will see later, the view should observe the model only after React has mounted the view component. So it doesn't make sense to define an observe() function in the view.

The View object — UserView

The Intent object has to observe the View object. This can be achieved by creating a stream in the view object and having the intent object subscribe to the stream. Normally, it would have looked something like this:

var View = {
    stream : new Rx.Subject(),
    handleSomeUIEvent: function(someEvent) {
        this.stream.onNext(someEvent.getData());
    }
};

var Intent = {
    observe : function(view) {
        view.stream.subscribe(function(data) {
            // Do something with the data put out by the view
        })
    }
};

Intent.observe(View);

But since React manages the lifecycle of the component objects, we can't directly define a stream object inside a React component. We need to define it outside the component and access it by defining static methods in the component's statics object.

In our example, the file UserView.jsx defines the React component. We will define the RxJS streams outside the React component and return them via the statics object. The intent object will then use these static methods.

In src/components/UserView.jsx:

// Define the RxJS streams to which our View object will emit data to
var loadUsers = new Rx.Subject();
var deleteUser = new Rx.Subject();
var createUser = new Rx.Subject();

var MainView = React.createClass({
    statics: {
        getLoadUsers: function() {
            return loadUsers;
        },
        getDeleteUser: function() {
            return deleteUser;
        },
        getCreateUser: function() {
            return createUser;
        }
    },
    .
    .
}

Next in the lifecycle function componentDidMount(), we will subscribe to the store's stream. As we will see later in the section for UserStore, the objects emitted to the stream are the list of users. Remember that the store object was passed to the component via the props object. On receiving the user list from the store, we set them in our component's state object. Updating the state object will cause React to re-render the component.

Then we will emit an object to the loadUsers stream. This will eventually cause the UserStore to fetch the users by calling a REST API and then emit them to the store's stream.

    getInitialState: function() {
        return {users: []};// Initialize the component state with an empty users array
    },
    updates: null,
    componentDidMount: function() {
        this.updates = this.props.store.updates.subscribe(function(data) {
            this.setState({users: data});
        }.bind(this));
        loadUsers.onNext();
    }

Calling subscribe directly on the store's stream object — via this.props.store.updates.subscribe — is how the view observes the model. As I mentioned earlier, since React manages the component lifecycle, we can't define an observe() function in our view to make it observe the model.

Note that after calling subscribe on store's stream object, we assign the subscriber to a variable this.updates. We need a handle on this subscriber object because when React unmounts the component, we need to dispose off the subscriber. Otherwise it will continue subscribing to the model and will try to update the component's state whenever the model emits data. Since the component is not mounted, React won't know how to re-render it and will throw an error. So in componentWillUnmount(), we dispose off the subscriber.

    componentWillUnmount: function() {
        this.updates.dispose();
    }

As far as our example is concerned, the view component never gets unmounted. But I still included the above piece of code to show how to stop the component from subscribing to the store. If and when the component is mounted again, componentDidMount() will be called and the component will again subscribe to the store.

The rest of the code in UserView should be self-explanatory. We read the list of users and display them in a table. We add a delete button for each user. Clicking it will cause the view to emit the id of the user to delete to the stream deleteUser. Similarly, creating a new user will emit an object to the stream createUser.

The Intent object — UserIntent

The intent object observes the view object, i.e. it subscribes to the streams defined in the view object. And when the subscribers receive objects emitted the view's streams, the intent object in turn emits objects to its own stream. Remember that the model object observes the intent object and so will be subscribing to the intent's stream (UserIntent.actions).

In src/intents/UserIntent.js:

var UserIntent = {};

UserIntent.actions = new Rx.Subject();

UserIntent.observe = function(reactComponent) {
    reactComponent.getLoadUsers().subscribe(function() {
        UserIntent.actions.onNext(new ActionEvent(AppConstants.LOAD, null));
    });
    reactComponent.getDeleteUser().subscribe(function(id) {
        UserIntent.actions.onNext(new ActionEvent(AppConstants.DELETE, id));
    });
    reactComponent.getCreateUser().subscribe(function(name) {
        UserIntent.actions.onNext(new ActionEvent(AppConstants.CREATE, {
            id: Math.floor((Math.random() * 1000) + 10), // Generate a unique id
            name: name
        }));
    });
};

ActionEvent is a just a wrapper class. It has two members — type and data. type tells what kind of action needs to be taken — like load users list, delete a user or create a user. And data is what the action will be taken on. This will become clear once you look at the next section on the model object.

The Model object — UserStore

The model object observes the intent object. So in src/stores/UserStore.js, this is how the observe() function looks like:

Userstore.observe = function(intent) {
        intent.actions.subscribe(function(actionEvent) {
        switch(actionEvent.action) {
            case AppConstants.LOAD:
                load();
                break;
            case AppConstants.CREATE:
                create(actionEvent.data);
                break;
            case AppConstants.DELETE:
                del(actionEvent.data);
                break;
            default:
            // no op
        }
    });
};

We subscribe on the model's stream object (intent.actions). Depending on the value of actionEvent.action, we call functions that interface with a REST API. For example, here's how load() looks like:

var RESOURCE_URL = "http://localhost:3000/users/";
function load() {
    $.ajax(RESOURCE_URL).done(function(data) {
    	sendUpdate(data);
    })
}

RESOURCE_URL is the url of the REST API. We use jQuery's $.ajax to make a HTTP GET request and after we get a response, we call another function sendUpdate(). This function will emit data onto the model's stream object:

Userstore.updates = new Rx.Subject();

function sendUpdate(data) {
    Userstore.updates.onNext(data);
}

As we've seen above, the view component subscribes to this stream object.

The rest of the functions in UserStore.js should be self explanatory.

In conclusion

So there you have it, an example of how to implement Mode-View-Intent with React by using RxJS streams and subscribers. However, it should be noted that it isn't a "pure" implementation. Our view couldn't call observe() on the model and we had to define static methods in the view in order for the intent to observe it. These hacks were needed because React manages the view object and not us. But on the whole, I think these hacks don't actually prevent us from achieving our end goal — that the model, view and intent objects should update themselves in a reactive way and not in an imperative way.


Comments (0)

 

Leave a Comment

HTML Syntax: Allowed