Introduction
In web applications, Reactive Extensions and RxJS can be viewed as "lodash for asynchronous collections", it offers powerful functional constructs and operators to reduce the complexities when web application features scales with the asynchronous nature of web. It also changes programming model from “pull” to “push”, conceptually unifies the data model and behavior across async-array and events, enables effective data transformation with data streams, ultimately simplifies application state management logic with a cleaner approach.
The scope of asynchronous collection includes not only data (array and object, primarily), but also events (keyboard, mouse, gestures, etc.), Promises (Ajax calls, for example) and time based intervals data streams, etc. Although RxJS also runs on Node.js, this article focuses its event streams in user interfaces with React.JS components.
When it comes to asynchronous event processing, RxJS provides greater expressiveness and simplicity; its powerful and abundant operators can transform, translate, filter, aggregate and reconstruct event data when it "flows in the stream". This is the essence of how ‘reactive’ works: observable async data collection serves as data source, transforms data within the stream via operators, while observer subscribes to observable source updates, passes the result to React as component state. React will re-render the component via performant virtual DOM whenever component state updates.
This article is also a follow-up to the "Component Based Web Application". After we selected React as component model, we’re looking for ways to make intra-component state updates to be more effective and concise. We’ll leave the inter-component interaction (implementing Flux by RxJS) to a different discussion, just focus on the component itself.
Intra-component state is autonomous; it refers to the state that is encapsulated within the component, not passed down from Controller (Container or Mediator) component as immutable properties. Rather, autonomous state is mutable, managed by the component itself, and usually reactive to user input. As we’ll see in the following example, when component has stream-like (continuously updates) and self-contained state data needs to be managed, RxJS’ expressiveness and simplicity is a great help.
The code example is to implement a reusable dropdown menu, it has highlighted menu item that is movable with up/down arrow keys.
Overview
In the dropdown menu use case, requirements are:
- when dropdown collapses: up/down arrow keys are enabled to highlight menu item, press enter to execute menu command and close the dropdown
- any mouse click, escape or tab key will close the dropdown: keyboard and mouse event handling should be stopped
We can certainly implement it by classic DOM addEventListener / removeEventListener, or use SyntheticEvent from React.JS. With RxJS, the code can be cleaner and more expressive.
Here are some basic RxJS constructs and concepts to utilize:
With the help of RxJS, here is our overall solution:
- Dropdown menu component will has one autonomous state: hightLightIdx, default to -1, represents the index of the menu item that needs to be highlighted, mutable by user inputs
- Menu items data will be passed in from Controller/Container component as properties, stays immutable
- One Rx stream for highlighting transforms key presses to hightLightIdx, either up or down
- Another Rx stream for user input captures all triggers to close dropdown: mouse clicks, tab and escape keys
We’ll set up our component first, initialize the two streams, then attach/detach observers in component life cycle callbacks to make RxJS work with React. Let’s see some code now.
Component Setup
Here is the skeleton of our dropdown menu React component, the default properties show the data structure it expects and default state only has hightLightIdx
:
'use strict';
import React from 'react';
import Rx from 'rx';
const MENU_STYLES = "dropdown-menu-section";
let MenuBarDropDown = React.createClass({
getDefaultProps() {
return {
active: "",
menuData: {
label: "",
items: [
{
label: "", icon: "", action: ""
}
]
},
onMenuItemAction: () => {}
};
},
getInitialState() {
return {
highLightIdx: -1
};
},
render() {
let dropDownStyle = this.props.active ? MENU_STYLES + " active" : MENU_STYLES;
return (
<ul className={dropDownStyle} role="menu">
{this.renderMenuItems()}
</ul>
);
},
renderMenuItems() {
return this.props.menuData.items.map( (item, idx) => {
if (item.role === 'separator') {
return (<li key={item.role + idx} role="separator" className="divider"></li>);
}
else {
let itemStyle = this.state.highLightIdx === idx ? 'highlight' : '';
if (item.disabled) {
itemStyle = "disabled";
}
return (
<li key={item.action} role="presentation" className={itemStyle}>
<a href={item.label} onClick={this.onMenuItemAction.bind(this, idx)}>
<i className={"icon " + item.icon}></i>{item.label}
</a>
</li>
);
}
});
}
});
module.exports = MenuBarDropDown;
Note the active
property, it enables the controller/container component to control the collapse or close the drop down. Together with the active
CSS class, it enables the dropdown menu component reusable in either a menu-bar container (with multiple instance of drop downs) or as context menu (single instance).
The highLightIdx
state is only referenced in renderMenuItems()
method, which is invoked from render()
, it adds or removes the CSS class of highlight
.
Although we haven’t add any Rx code yet, the component is already “reactive” to its state and props updates by React’s re-rendering mechanism. The higher order controller/container component (menu bar or context menu) has no interest on which menu item is highlighted, and how highlighted item is moving up and down, it only cares about which command to execute. This separation of concerns across component boundary makes highLightIdx
an autonomous state, entirely encapsulated and managed within the dropdown component itself.
One beautify of this component and reactive patterns is: when extend the component with highlighting behaviors, as we’ll do as next step, all rendering code above stay intact, no code change required to accommodate the new behaviors, open/closed principle can be easily complied.
Let’s extend the component’s behavior by using RxJS now.
Stream Setup
We can write all the data transformation code when we setup the observable streams, these observables will stay idle till subscribe() is invoked. When we’re done with the data stream, we can dispose the subscription what is returned from subscribe() call.
Here is how Rx highlight stream is initialized:
initStream() {
let keyPresses = Rx.Observable.fromEvent(document, 'keyup').map( e => e.keyCode || e.which);
let upKeys = keyPresses.filter( k => k === 38 ).
map( (k) => this.getIdxToHighLight('up', this.state.highLightIdx) );
let downKeys = keyPresses.filter( k => k === 40 ).
map( (k) => this.getIdxToHighLight('down', this.state.highLightIdx) );
this.highLightStream = Rx.Observable.merge(upKeys, downKeys).debounce(100 );
}
The highlightStream
originates from document.onkeyup
, translates to key code, and further transforms to a new calculated value of highLightIdx
. When we merge these two streams (upKeys
and downKeys
) into one, it enables the observer to process the new value of highLightIdx
, rather than the key events.
Same idea, we can merge another two streams that would close the dropdown: mouse clicks and tab/escape keys: (the code below is part of the initStream function also):
let mouseClicks = Rx.Observable.fromEvent(document, 'click').map( evt => -1 );
let escapeKeys = keyPresses.filter( k => k === 27 || k === 9).map( k => -1 );
let enterKeys = keyPresses.filter(k => k === 13).map(k => this.state.highLightIdx);
this.userInputStream = Rx.Observable.merge(mouseClicks, escapeKeys, enterKeys).debounce(100 );
When this.userInputStream
updates with -1 (mouseClicks
, escapeKeys
), we need to close the menu and stop processing; While enterKeys
updates the stream with current highlighted item index, we need to execute the command identified by the index first, then close the menu.
The above initStream()
with two merged Rx streams are ready, we can call it when component is inserted to the DOM:
componentDidMount() {
this.initStream();
}
The observable streams setup is completed, now let’s look at observers.
Reactive Observers
Both componentDidMount()
and componentDidUpdate()
are component lifecycle callbacks from React, the former calls initStream()
to make the event stream and data transformation ready, and the latter is the hook to trigger subscribe()
and dispose()
calls:
componentDidUpdate(prevProps, prevState) {
if (prevProps.active !== this.props.active) {
this.handleMenuState(this.props.active === 'active');
}
}
As we discussed earlier, the active
property is passed from controller component to collapse/close the dropdown. All handleMenuState()
does is to start/stop the stream processing:
handleMenuState(shown) {
if (shown) {
this.highLightSub = this.highLightStream.subscribe((hIdx) => this.setState({highLightIdx: hIdx }));
this.userInputSub = this.userInputStream.subscribe((hIdx) => this.onMenuItemAction(this.state.highLightIdx));
}
else {
this.highLightSub.dispose();
this.userInputSub.dispose();
}
}
Now we have a fully functioning dropdown menu, items can be highlighted by arrow keys, command can be executed by clicks or enter key, click outside or press escape/tab key will close the dropdown. We’re almost done except one more case: we need to capture all mouse clicks when menu drops down.
If some DOM elements “swallow the click event”, (like iFrame
, or stopPropagation()
API calls that cancels event propagation), our mouseClicks
stream will not be updated, since it’s attached to bubbled up click event in document
. We need to make sure we capture all clicks events so that clicking inside an iframe can also dismiss the collapsed menu.
Tighten Up
There are different ways to capture mouse clicks, the approach we use is a dynamically inserted fixed positioned full screen “backdrop” layer underneath the drop down. The CSS rule is:
.dropdown-backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
background-color: transparent;
}
To set its z-index to be 999 is to make sure the backdrop element is located right behind our drop down menu, which has z-index as 1000:
.dropdown-menu-section {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 13px;
text-align: left;
background-color: $color-darker-grey;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 3px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
background-clip: padding-box;
&.active {
display: block;
}
> li {
&.highlight {
background-color: $color-accent;
}
&.disabled {
a {
color: $color-dark-grey;
cursor: default;
&:hover {
background-color: $color-darker-grey;
color: $color-dark-grey;
}
}
}
&.divider {
height: 1px;
margin: 6px 0;
overflow: hidden;
background-color: $color-dark-grey;
}
i {
width: 20px;
display: inline-block;
}
> a {
outline: none;
text-decoration: none;
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.42857;
color: #fff;
white-space: nowrap;
&:hover {
background-color: $color-dark-blue;
color: $color-lighter;
}
}
}
}
That’s all our CSS rules for dropdown menu and different type of menu items (highlight, disabled and divider).
To create the backdrop element, we can extend the componentDidMount() callback:
componentDidMount() {
this.initStream();
this.clickBackDrop = document.createElement('div');
this.clickBackDrop.className = 'dropdown-backdrop';
},
Now that the backdrop element is ready, we can further extend handleMenuState()
to insert / remove the back drop when menu drops down or closes:
handleMenuState(shown) {
document.activeElement.blur();
if (shown) {
document.body.appendChild(this.clickBackDrop);
this.highLightSub = this.highLightStream.subscribe((hIdx) => this.setState({highLightIdx: hIdx }));
this.userInputSub = this.userInputStream.subscribe((hIdx) => this.onMenuItemAction(this.state.highLightIdx));
}
else {
this.highLightSub.dispose();
this.userInputSub.dispose();
document.body.removeChild(this.clickBackDrop);
}
}
Note we didn’t attach event handler to the backdrop element, we only insert it when mouse capturing is desired (menu drops down) and remove it when stream processing needs stop (menu closes).
That’s it.
Wrap up
With the drop down menu example, we find out the state management code is quite clean and straightforward with Rx. No messy event data manipulation within event handlers, no imperative DOM manipulations when rendering component either. Data streams originate from DOM events, effectively transformed to component state, code becomes shorter, cleaner, easy to read and maintain.
RxJS provides an alternative way to handle asynchronous events and data, conceptually unifies event and array/object with stream. Comparing to classic DOM event handling, Reactive Extensions and RxJS has higher level of abstraction and expressiveness, really makes autonomous state management within ReactJS component a breeze.