Development of javascript applications based on Rx.js and React.js (RxReact)

  • Tutorial
rxreactlogo

React.js allows you to work very efficiently and quickly with the DOM, is actively developing and is gaining more and more popularity every day. Recently discovered the concept of reactive programming, in particular, not less popular library Rx.js . This library takes to a new level the work with events and asynchronous code, which is abundant in the javascript UI logic. The idea came to combine the power of these libraries into a single whole and see what comes of it. In this article, you will learn how to make friends Rx.js and React.js.


RxReact - a new library?


Maybe someone will be disappointed - but no. One of the positive aspects of this approach is that you do not need to install any additional new libraries. Therefore, I did not bother much and called this approach RxReact .
For the impatient, a repo with test cases .

What for?


Initially, when I first got acquainted with React, I was not at all shy about stuffing components with business logic, ajax requests, etc. But as practice has shown, interfering with everything inside the React components by subscribing to various hooks while maintaining an intermediate mutable state is an extremely bad idea. It becomes difficult to make changes and understand such components - monsters. React in my view is ideally suited only for rendering a specific state (nugget) of an application at a certain point in time, but the logic of how and when this state will change does not matter at all and should be in a different layer of abstraction. The less the presentation layer knows about it, the calmer we sleep. I wanted to bring React components as close as possible to purefunctions without mutable, stored state, unnecessary side effects, etc. At the same time, I wanted to improve my work with events, it is advisable to put in a separate layer of logic a declarative description of how the application should interact with the user, respond to various events and change its state. In addition, I wanted to be able to compose chains of sequences of actions from synchronous and asynchronous operations.

No it's not really flux


An inquisitive reader who has read up to this point, already several times might have thought: "So there is Flux - take it and use it." Just recently I looked at him and, to my surprise, found a lot of similarities with the concept about which I want to tell you. At the moment, I have already seen several Flux implementations . RxReact is no exception, but in turn has a slightly different approach. It turned out that he himself involuntarily came to almost the same architectural components as: dispatcher , storage , actions . They are very similar to those described in the Flux architecture .

Main components


I hope that you managed to intrigue you with something and you read to this place, because here the most delicious begins. For a more illustrative example, we will consider a test application:
demo
Demo site - demo1 .
The source is here .

The application itself does not do anything useful, just a click counter on the button.

View

The presentation layer is a React component whose main purpose is to draw the current state and signal events in the UI.

So what should view be able to do?
  • draw ui
  • signal events in the UI

Below is the view code from the example ( view.coffee ):
React = require 'react'
{div, button} = React.DOM
HelloView = React.createClass
    getDefaultProps: ->
        clicksCount: 0
    incrementClickCount: ->
        @props.eventStream.onNext
            action: "increment_click_count"
    render: ->
        div null,
            div null, "You clicked #{@props.clicksCount} times"
            button
                onClick: @incrementClickCount
                "Click"
module.exports = HelloView

javascript version of view.coffee file
var React = require('react');
var div = React.DOM.div;
var button = React.DOM.button;
HelloView = React.createClass({
  getDefaultProps: function() {
    return {
      clicksCount: 0
    };
  },
  incrementClickCount: function() {
    return this.props.eventStream.onNext({
      action: "increment_click_count"
    });
  },
  render: function() {
    return div(null,
        div(null, "You clicked " + this.props.clicksCount + " times"),
              button({onClick: this.incrementClickCount}, 
              "Click"));
}});
module.exports = HelloView;



As you can see, all the click data comes to us “from above” through the props object . When we click on the button, we send action through the eventStream channel . View signals us button clicks using eventStream.onNext , where eventStream is an Rx.Subject instance . Rx.Subject - a channel into which you can both send messages and create subscribers from it. Further it will be considered in more detail how to work with Rx.Subject .

After we clearly defined the view and message channel functions, they can be distinguished in the structural diagram:
view_layer
As you can see, view is a React component, receives the current state of the application (app state), sends event messages via event stream (actions). In this scheme, Event Stream is a communication channel between view and the rest of the application (depicted by a cloud). Gradually, we will determine the specific functions of the components and remove them from the general js application block.

Storage (Model)

The next component is Storage . Initially, I called it Model, but I always thought that model is not an entirely suitable name. Since the model in my view is a certain specific entity (User, Product), and here we have a set of different data (many models, flags) with which our application works. In Flux implementations, which I had to see, storage was implemented as a singleton module. There is no such need for my implementation. This gives the theoretical possibility of the painless existence of several application instances on the same page.

What storage can do?
  • store data
  • change data
  • return data


In my example, storage is implemented through a coffee class with certain properties ( storage.coffee ):
class HelloStorage
    constructor: ->
        @clicksCount = 0
    getClicksCount: -> @clicksCount
    incrementClicksCount: ->
        @clicksCount += 1
module.exports = HelloStorage

javascript version storage.coffee
var HelloStorage;
HelloStorage = (function() {
  function HelloStorage() {
    this.clicksCount = 0;
  }
  HelloStorage.prototype.getClicksCount = function() {
    return this.clicksCount;
  };
  HelloStorage.prototype.incrementClicksCount = function() {
    return this.clicksCount += 1;
  };
  return HelloStorage;
})();
module.exports = HelloStorage;



Storage itself has no idea about the UI, that there is some kind of Rx and React. The store does what it should do by definition - store data (application state).

On the block diagram, we can highlight storage:
storage layer

Dispatcher

So, we have a view - draws the application at a certain point in time, storage - in which the current state is stored. There is not enough connecting component that will listen to events from view, if necessary, change the state and give the command to update the view. Such a component is the dispatcher .

What should a dispatcher be able to do?
  • respond to events from view
  • update data in storage
  • initiate view updates


From the point of view of Rx.js, we can consider view as an endless source of certain events for which we can create subscribers. In the demo example, we have only one subscriber in the dispatcher — a subscriber to clicks on the increase value button.

Here is how a button click subscription in the dispatcher code will look:
incrementClickStream = eventStream.filter(({action}) -> action is "increment_click_count")

javascript version
var incrementClickStream = eventStream.filter(function(arg) {
  return arg.action === "increment_click_count";
});


For a more complete understanding, the code above can be clearly depicted as follows:
image
In the image we see 2 message channels. The first one is eventStream (the base channel) and the second one received from the base channel is incrementClickStream. The circles depict the sequence of events in the channel; in each event, the action argument is passed, by which we can filter (dispatch).
Let me remind you that view sends a message to the channel with a call:
eventStream.onNext({action: "increment_click_count"})


The resulting incrementClickStream is an Observable instance and we can work with it in the same way as with eventStream, which we will do in principle. And then we must indicate that for each click on the button we want to increase the value in storage (change the state of the application).

incrementClickStream = eventStream.filter(({action}) -> action is "increment_click_count")
                                  .do(-> store.incrementClicksCount())

javascript version
var incrementClickStream = eventStream.filter(function(arg) {
  return arg.action === "increment_click_count";
}).do(function() {
  return store.incrementClicksCount();
});


Schematically it looks like this:

streamdo

This time we get a source of values ​​that should update the view, as the state of the application changes (the number of clicks increases). In order for this to happen, you must subscribe to the incrementClickStream source and call setProps on the react component that renders the view.

incrementClickStream.subscribe(-> view.setProps {clicksCount: store.getClicksCount()})

javascript version
incrementClickStream.subscribe(function() {
  return view.setProps({
    clicksCount: store.getClicksCount()
  });
});



Thus, we close the chain and our view will be updated every time we click on the button. There can be many such sources updating the view, therefore it is advisable to combine them into a single event source using Rx.Observable.merge .

Rx.Observable.merge(
    incrementClickCountStream
    decrementClickCountStream
    anotherStream # e.t.c)
  .subscribe(
     -> view.setProps getViewState(store)
     -> # error handling
  )

javascript version
Rx.Observable.merge(
  incrementClickCountStream,
  decrementClickCountStream,
  anotherStream)
.subscribe(
  function() {
    return view.setProps(getViewState(store));
  },
  function() {}); // error handling



The getViewState function appears in this code . This function just takes the data needed for the view from storage and returns it. In the demo example, it looks like this:

getViewState = (store) ->
    clicksCount: store.getClicksCount()

javascript version
var getViewState = function(store) {
  return {
    clicksCount: store.getClicksCount()
  };
};



Why not pass storage directly to view? Then, so that there is no temptation to write something directly from view, call unnecessary methods, etc. View receives data prepared specifically for display in the visual part of the application, no more and no less.

Schematically, the merge of the sources looks like this:

stream_merge

It turns out, in addition to the fact that we do not need to call any “onUpdate” events from the model to update the view, we also have the ability to handle errors in one place. The second argument to subscribe is a function to handle errors. It works on the same principle as in Promise. Rx.Observable has much in common with promises, but it is a more advanced mechanism, since it considers not the only promised value, but an infinite sequence of returned values ​​over time.

The full dispatcher code looks like this:

Rx = require 'rx'
getViewState = (store) ->
    clicksCount: store.getClicksCount()
dispatchActions = (view, eventStream, storage) ->
    incrementClickStream = eventStream  # получаем источник кликов
        .filter(({action}) -> action is "increment_click_count")
        .do(-> storage.incrementClicksCount())
    Rx.Observable.merge(
        incrementClickStream
        # и еще много источников обновляющих view...
    ).subscribe(
        ->
         view.setProps getViewState(storage)
       (err) ->
         console.error? err)
module.exports = dispatchActions

javascript version
var Rx = require('rx');
var getViewState = function(store) {
  return {
    clicksCount: store.getClicksCount()
  };
};
var dispatchActions = function(view, eventStream, storage) {
  var incrementClickStream = eventStream.filter(function(arg) {
      return arg.action === "increment_click_count";})
   .do(function() {
      return storage.incrementClicksCount();
  });
  return Rx.Observable.merge(incrementClickCountStream)
    .subscribe(function() {
      return view.setProps(getViewState(storage));
    }, 
    function(err) {
      return typeof console.error === "function" ? console.error(err) : void 0;
  });
};
module.exports = dispatchActions;



The full file code is dispatcher.coffee.

All the dispatching logic is placed in the dispatchActions function , which accepts:

  • view - instance of the React component
  • storage - storage instance
  • eventStream - message channel


Having placed the dispatcher on the circuit, we have a complete structural diagram of the application architecture:

image

Component Initialization

Next, it remains for us to somehow initialize: view, storage and dispatcher. Let's do it in a separate file - app.coffe :
Rx = require 'rx'
React = require 'react'
HelloView = React.createFactory(require './view')
HelloStorage = require './storage'
dispatchActions = require './dispatcher'
initApp = (mountNode) ->
    eventStream = new Rx.Subject() #  создаем канал сообщений
    store = new HelloStorage() #  cоздаем хранилище
    # получаем инстанс отрисованного view
    view = React.render HelloView({eventStream}), mountNode
    # передаем компоненты в dispatcher
    dispatchActions(view, eventStream, store)
module.exports = initApp

javascript version
var Rx = require('rx');
var React = require('react');
var HelloView = React.createFactory(require('./view'));
var HelloStorage = require('./storage');
var dispatchActions = require('./dispatcher');
initApp = function(mountNode) {
  var eventStream = new Rx.Subject();
  var store = new HelloStorage();
  var view = React.render(HelloView({eventStream: eventStream}), mountNode);
  dispatchActions(view, eventStream, store);
};
module.exports = initApp;



The initApp function accepts mountNode as input. Mount Node, in this context, is the DOM element into which the root React component will be drawn.

RxRact module base structure generator (Yeoman)

You can use Yeoman to quickly create the above components in a new application .
Generator - generator-rxreact

More complicated example


The example with one source of events shows well the principle of interaction of components, but does not demonstrate at all the advantage of using Rx in conjunction with React. As an example, let's imagine that, on demand, we must improve the 1st demo example in this way:

  • ability to decrease value
  • save it to the server when changing, but no more than once per second and only if it has changed
  • show successful save message
  • hide successful save message after 2 seconds


In the end, you should get this result:
demo2

Demo site - demo2 .
The source code for demo2 is here .

I will not describe the changes in all components, I will show the most interesting thing - changes in the dispatcher and try to comment as much as possible on what is happening in the file:

Rx = require 'rx'
{saveToDb} = require './transport' # импортируем асинхронную функцию (эмуляция синхронизации с базой данных)
getViewState = (store) ->
    clicksCount: store.getClicksCount()
    showSavedMessage: store.getShowSavedMessage() # в view state добавился флаг отображаить или нет 
                                                                                          # сообщение об успешном сохранении
dispatchActions = (view, eventStream, store) ->
    # источник "+1" кликов
    incrementClickSource = eventStream
        .filter(({action}) -> action is "increment_click_count")
        .do(-> store.incrementClicksCount())
        .share()
    # источник "-1" кликов
    decrementClickSource = eventStream
        .filter(({action}) -> action is "decrement_click_count")
        .do(-> store.decrementClickscount())
        .share()
    # Соединяем два источника кликов в один
    countClicks = Rx.Observable
        .merge(incrementClickSource, decrementClickSource)
    # Обработка кликов (-1, +1)
    showSavedMessageSource = countClicks
        .throttle(1000) # ставим задержку 1 секунду
        .distinct(-> store.getClicksCount()) # реагируем только если изменилось число кликов
        .flatMap(-> saveToDb store.getClicksCount()) # вызываем асинхронную функцию сохранения
        .do(-> store.enableSavedMessage()) # показываем сообщение об успешном сохранении
    # создаем подписчика, который спрячет сообщение об успешном сохранении после 2 секунд
    hideSavedMessage = showSavedMessageSource.delay(2000)
    .do(-> store.disableSavedMessage())
    # Соединяем все источники в один, который будет обновлять view
    Rx.Observable.merge(
        countClicks
        showSavedMessageSource
        hideSavedMessage
    ).subscribe(
        -> view.setProps getViewState(store)
        (err) ->
            console.error? err)
module.exports = dispatchActions

I hope that you, like me, are impressed by the ability to declaratively describe the operations performed in our application, while creating composable computational chains consisting of synchronous and asynchronous actions.
This will end the story. I hope that I managed to convey the main point of using the concepts of reactive programming and React for building custom applications.

Several links from the article



PS All the demos from the article use server side prerendering for React.js, for this I created a special gulp plugin - gulp-react-render .

Also popular now: