📜 ⬆️ ⬇️

Using RxJS in React Development to Manage Application State

The author of the material, the translation of which we are publishing today, says that here he wants to demonstrate the process of developing a simple React-application using RxJS. According to him, he is not an expert in RxJS, as he himself is studying this library and does not refuse the help of knowledgeable people. Its goal is to draw the attention of the audience to alternative ways of creating React-applications, to inspire the reader to do independent research. This material cannot be called an introduction to RxJS. One of the many ways to use this library in React development will be shown here.

image

How it all began


Recently, my client inspired me to learn how to use RxJS to manage the state of React applications. When I conducted an audit of the application code of this client, he wanted to know my opinion on how to develop the application for him, given that before that he used only the local state of React. The project has reached a level where it was unreasonable to rely solely on React. At first we talked about using Redux or MobX as state control tools . My client created a prototype for each of these technologies. But he didn’t limit himself to these technologies by creating a prototype of a React application that uses RxJS. From this point on our conversation became much more interesting.

The application in question was a trading platform for cryptocurrency. It had many widgets whose data was updated in real time. The developers of this application, among others, had to solve the following difficult tasks:
')

As a result, the main difficulties faced by the developers did not relate to the React library itself, and besides, I could help them in this area. The main problem was to make the internal mechanisms of the system work correctly, such as those that linked cryptocurrency data and interface elements created by means of React. It was in this area that the RxJS capabilities were very helpful, and the prototype they showed me looked very promising.

Using RxJS in React


Suppose that we have an application that, after performing some local actions, makes requests to a third-party API . It allows you to search for articles. Before making a request, we need to get the text that is used to form this request. In particular, we use this text to generate a URL for accessing the API. Here is the code of the React-component that implements this functionality.

import React from 'react'; const App = ({ query, onChangeQuery }) => (  <div>    <h1>React with RxJS</h1>    <input      type="text"      value={query}      onChange={event => onChangeQuery(event.target.value)}    />    <p>{`http://hn.algolia.com/api/v1/search?query=${query}`}</p>  </div> ); export default App; 

This component lacks a state management system. The state for the query property is not stored anywhere, the onChangeQuery function also does not update the state. In the usual approach, such a component is equipped with a local state management system. It looks like this:

 class App extends React.Component { constructor(props) {   super(props);   this.state = {     query: '',   }; } onChangeQuery = query => {   this.setState({ query }); }; render() {   return (     <div>       <h1>React with RxJS</h1>       <input         type="text"         value={this.state.query}         onChange={event =>           this.onChangeQuery(event.target.value)         }       />       <p>{`http://hn.algolia.com/api/v1/search?query=${         this.state.query       }`}</p>     </div>   ); } } export default App; 

However, this is not the approach that we will talk about here. Instead, we want to set up an application state management system using RxJS. Let's take a look at how to do this using Higher-Order Component (HOC).

If you wish, you can implement similar logic in your App component, but you, most likely, at some point working on the application, decide to arrange such a component in the form of a HOC that is suitable for reuse.

React and higher order RxJS components


Let us understand how to manage the state of React-applications using RxJS, applying for this purpose a component of the highest order. Instead, one could implement the render props template . As a result, if you do not want to create higher order components for this purpose yourself, you can use the observed higher order components with mapPropsStream() and componentFromStream() . In this guide, however, we will do everything on our own.

 import React from 'react'; const withObservableStream = (...) => Component => { return class extends React.Component {   componentDidMount() {}   componentWillUnmount() {}   render() {     return (       <Component {...this.props} {...this.state} />     );   } }; }; const App = ({ query, onChangeQuery }) => ( <div>   <h1>React with RxJS</h1>   <input     type="text"     value={query}     onChange={event => onChangeQuery(event.target.value)}   />   <p>{`http://hn.algolia.com/api/v1/search?query=${query}`}</p> </div> ); export default withObservableStream(...)(App); 

While the higher order component RxJS does not perform any actions. It only transfers its own state and properties to the input component, which is planned to be expanded with its help. As you can see, the state of React will ultimately be managed by a higher order component. However, this state will be obtained from the observed flow. Before we start implementing HOC and using it with the App component, we need to install RxJS:

 npm install rxjs --save 

Now let's start using the higher-order component and implementing its logic:

 import React from 'react'; import { BehaviorSubject } from 'rxjs'; ... const App = ({ query, onChangeQuery }) => ( <div>   <h1>React with RxJS</h1>   <input     type="text"     value={query}     onChange={event => onChangeQuery(event.target.value)}   />   <p>{`http://hn.algolia.com/api/v1/search?query=${query}`}</p> </div> ); const query$ = new BehaviorSubject({ query: 'react' }); export default withObservableStream( query$, {   onChangeQuery: value => query$.next({ query: value }), } )(App); 

App component itself does not change. We only passed two arguments to a higher order component. We describe them:


After creating the observed object and subscribing to it, the thread for the request should work. However, until now the highest order component itself looks like a black box to us. We implement it:

 const withObservableStream = (observable, triggers) => Component => { return class extends React.Component {   componentDidMount() {     this.subscription = observable.subscribe(newState =>       this.setState({ ...newState }),     );   }   componentWillUnmount() {     this.subscription.unsubscribe();   }   render() {     return (       <Component {...this.props} {...this.state} {...triggers} />     );   } }; }; 

The higher-order component gets the observed object and the object with triggers (perhaps this object containing functions can be called some more successful term from the RxJS lexicon) represented in the function signature.

Triggers are only passed through the HOC input component. That is why the App component directly receives the onChangeQuery() function, which directly works with the observed object, passing new values ​​to it.

The observed object uses the componentDidMount() life cycle method for signing and the componentDidMount() method for unsubscribing. Cancellation is needed to prevent memory leaks . In the subscription of the observed object, the function only sends all incoming data from the stream to the local React state storage using the command this.setState() .

Perform a small change to the App component, which will eliminate the problem that arises if the higher order component does not set the initial value for the query property. If this is not done, then, at the beginning of the work, the query property will be equal to undefined . Due to this change, this property gets the default value.

 const App = ({ query = '', onChangeQuery }) => ( <div>   <h1>React with RxJS</h1>   <input     type="text"     value={query}     onChange={event => onChangeQuery(event.target.value)}   />   <p>{`http://hn.algolia.com/api/v1/search?query=${query}`}</p> </div> ); 

Another way to deal with this problem is to set the initial state for the query in a higher-order component:

 const withObservableStream = ( observable, triggers, initialState, ) => Component => { return class extends React.Component {   constructor(props) {     super(props);     this.state = {       ...initialState,     };   }   componentDidMount() {     this.subscription = observable.subscribe(newState =>       this.setState({ ...newState }),     );   }   componentWillUnmount() {     this.subscription.unsubscribe();   }   render() {     return (       <Component {...this.props} {...this.state} {...triggers} />     );   } }; }; const App = ({ query, onChangeQuery }) => ( ... ); export default withObservableStream( query$, {   onChangeQuery: value => query$.next({ query: value }), }, {   query: '', } )(App); 

If you now experience this application, the input field should work as expected. The App component receives from the HOC, in the form of properties, only the query state and the onChangeQuery function for changing the state.

Acquisition and state changes occur through observable RxJS objects, despite the fact that the internal storage of the React state is used inside a higher order component. I did not manage to find an obvious solution to the problem of streaming data from a subscription of observed objects directly to the properties of the advanced component ( App ). That is why I had to use the local state of React as an intermediate layer, which, moreover, is convenient in the sense that it causes re-rendering. If you know another way to achieve the same goals - you can share it in the comments.

Combining observed objects in React


Create a second stream of values, with which, as well as with the property query , you can work in the App component. Later we will use both values, working with them with the help of another observed object.

 const SUBJECT = { POPULARITY: 'search', DATE: 'search_by_date', }; const App = ({ query = '', subject, onChangeQuery, onSelectSubject, }) => ( <div>   <h1>React with RxJS</h1>   <input     type="text"     value={query}     onChange={event => onChangeQuery(event.target.value)}   />   <div>     {Object.values(SUBJECT).map(value => (       <button         key={value}         onClick={() => onSelectSubject(value)}         type="button"       >         {value}       </button>     ))}   </div>   <p>{`http://hn.algolia.com/api/v1/${subject}?query=${query}`}</p> </div> ); 

As you can see, the subject parameter can be used to refine the request when building the URL used to access the API. Namely, materials can be searched based on their popularity or at the date of publication. Next, create another observable object that can be used to change the subject parameter. This observable object can be used to organize communication between the App component and the higher order component. Otherwise, the properties passed to the App component will not work.

 import React from 'react'; import { BehaviorSubject, combineLatest } from 'rxjs/index'; ... const query$ = new BehaviorSubject({ query: 'react' }); const subject$ = new BehaviorSubject(SUBJECT.POPULARITY); export default withObservableStream( combineLatest(subject$, query$, (subject, query) => ({   subject,   query, })), {   onChangeQuery: value => query$.next({ query: value }),   onSelectSubject: subject => subject$.next(subject), }, )(App); 

The onSelectSubject() trigger is not new. He, by means of the button, can be used to switch between the two states of the subject . But the observable object, transmitted to a higher-order component, is something new. It uses the combineLatest() function from RxJS to combine the latest values ​​returned from two (or more) observed flows. After the subscription to the observed object is registered, if any of the values ​​( query or subject ) change, the subscriber will receive both values.

An addition to the mechanism implemented by the combineLatest() function is its last argument. Here you can specify the order of returning the values ​​generated by the observed objects. In our case, we need them to be represented as an object. This will allow, as before, to destructurize them in a higher-order component and write them to the local state of React. Since we already have the necessary structure, we can omit the step of wrapping the object of the observed query object.

 ... const query$ = new BehaviorSubject('react'); const subject$ = new BehaviorSubject(SUBJECT.POPULARITY); export default withObservableStream( combineLatest(subject$, query$, (subject, query) => ({   subject,   query, })), {   onChangeQuery: value => query$.next(value),   onSelectSubject: subject => subject$.next(subject), }, )(App); 

The source object, { query: '', subject: 'search' } , as well as all other objects produced by the combined stream of observed objects, are suitable for destructuring them in a higher-order component and for writing the corresponding values ​​to the local React state. After updating the state, as before, rendering is performed. When you launch the updated application, you should be able to change both values ​​using the input field and the button. Changed values ​​affect the URL used to access the API. Even if only one of these values ​​changes, the other value maintains its last state, since the combineLatest() function always combines the most recent values ​​from the observed streams.

Axios and RxJS in React


Now, in our system, the URL for accessing the API is constructed on the basis of two values ​​from a combined observable object, which includes two other observable objects. In this section, we will use the URL to load data from the API. You may well know how to use the React data loading system , but when using observed RxJS objects, you need to add another observed stream to our application.

Before we work on the next observable object, we establish axios . This is the library that we will use to load data from streams into the program.

 npm install axios --save 

Now imagine that we have an array of articles that the App component should output. Here, as the value of the corresponding parameter by default, we use an empty array, acting in the same way as we did with other parameters.

 ... const App = ({ query = '', subject, stories = [], onChangeQuery, onSelectSubject, }) => ( <div>   ...   <p>{`http://hn.algolia.com/api/v1/${subject}?query=${query}`}</p>   <ul>     {stories.map(story => (       <li key={story.objectID}>         <a href={story.url || story.story_url}>           {story.title || story.story_title}         </a>       </li>     ))}   </ul> </div> ); 

For each article in the list, the use of a spare value is provided due to the fact that the API to which we refer is non-uniform. Now, the most interesting is the implementation of a new observable object that is responsible for loading data into a React application that will visualize them.

 import React from 'react'; import axios from 'axios'; import { BehaviorSubject, combineLatest } from 'rxjs'; import { flatMap, map } from 'rxjs/operators'; ... const query$ = new BehaviorSubject('react'); const subject$ = new BehaviorSubject(SUBJECT.POPULARITY); const fetch$ = combineLatest(subject$, query$).pipe( flatMap(([subject, query]) =>   axios(`http://hn.algolia.com/api/v1/${subject}?query=${query}`), ), map(result => result.data.hits), ); ... 

A new observable object is, again, a combination of the observable objects subject and query , since in order to build the URL with which we will access the API for loading data, we need both values. In the pipe() method of the observed object, we can use the so-called "RxJS operators" to perform certain actions with values. In this case, we are mapping two values ​​that are placed in the query, which axios uses to get the result. We use the flatMap() operator flatMap() map() to access the result of the successfully resolved promise and not the most returned promise. As a result, after subscribing to this new observable object, each time a new value of subject or query received from other observed objects in the system, a new query is executed, and the result is in the subscription function.

Now, again, we can provide a new observable object to a higher order component. We have the last argument of the combineLatest() function combineLatest() our disposal; this makes it possible to directly map it to a property named stories . In the end, this is how this data is already used in the App component.

 export default withObservableStream( combineLatest(   subject$,   query$,   fetch$,   (subject, query, stories) => ({     subject,     query,     stories,   }), ), {   onChangeQuery: value => query$.next(value),   onSelectSubject: subject => subject$.next(subject), }, )(App); 

There is no trigger here, since the observable object is indirectly activated by two other observable flows. Each time a value changes in the input field ( query ) or a button is clicked ( subject ), this affects the observed fetch object, which contains the most recent values ​​from both streams.

However, it is possible that we do not need that every time the value in the input field changes, this would affect the observed fetch object. In addition, we would not want the fetch to be affected if the value is represented by an empty string. That is why we can extend the observed query object using the debounce operator, which allows us to eliminate too frequent query changes. Namely, thanks to this mechanism, a new event is accepted only after a specified time after the previous event. In addition, we use the filter statement here, which filters out the flow events if the query string is empty.

 import React from 'react'; import axios from 'axios'; import { BehaviorSubject, combineLatest, timer } from 'rxjs'; import { flatMap, map, debounce, filter } from 'rxjs/operators'; ... const queryForFetch$ = query$.pipe( debounce(() => timer(1000)), filter(query => query !== ''), ); const fetch$ = combineLatest(subject$, queryForFetch$).pipe( flatMap(([subject, query]) =>   axios(`http://hn.algolia.com/api/v1/${subject}?query=${query}`), ), map(result => result.data.hits), ); ... 

The debounce operator does its job during data entry in the field. However, when you click on a button that affects a subject value, the request must be executed immediately.

Now the initial values ​​for query and subject , which we see when the App component is displayed for the first time, are not the same as those obtained from the initial values ​​of the observed objects:

 const query$ = new BehaviorSubject('react'); const subject$ = new BehaviorSubject(SUBJECT.POPULARITY); 

The subject is undefined , the query is an empty string. This is due to the fact that we provided these values ​​as default parameters for restructuring the signature of the App component function in the signature. The reason for this is that we need to wait for the initial request executed by the observed fetch object. Since I don’t know exactly how to immediately get the values ​​from the observed query and subject objects in the higher order component in order to write them to the local state, I decided to set up the initial state for the higher order component again.

 const withObservableStream = ( observable, triggers, initialState, ) => Component => { return class extends React.Component {   constructor(props) {     super(props);     this.state = {       ...initialState,     };   }   componentDidMount() {     this.subscription = observable.subscribe(newState =>       this.setState({ ...newState }),     );   }   componentWillUnmount() {     this.subscription.unsubscribe();   }   render() {     return (       <Component {...this.props} {...this.state} {...triggers} />     );   } }; }; 

Now the initial state can be provided to a higher order component as the third argument. In the future, we can remove the default settings for the App component.

 ... const App = ({ query, subject, stories, onChangeQuery, onSelectSubject, }) => ( ... ); export default withObservableStream( combineLatest(   subject$,   query$,   fetch$,   (subject, query, stories) => ({     subject,     query,     stories,   }), ), {   onSelectSubject: subject => subject$.next(subject),   onChangeQuery: value => query$.next(value), }, {   query: 'react',   subject: SUBJECT.POPULARITY,   stories: [], }, )(App); 

What worries me now is that the initial state is also set in the declaration of the observable objects query$ and subject$ . Such an approach is prone to errors, since the initialization of the observed objects and the initial state of a higher-order component share the same values. I would have liked it better if, instead, the initial values ​​would be extracted from the observed objects in the higher order component to set the initial state. Perhaps someone from the readers of this material will be able to share in the comments advice on how to do this.

The code of the software project that we were involved in here can be found here .

Results


The main goal of this material is to demonstrate an alternative approach to developing React applications using RxJS. We hope he gave you some food for thought. Sometimes there is no need for Redux and MobX, but perhaps in such situations, RxJS will turn out to be just the right thing for a particular project.

Dear readers! Do you use RxJS when developing React-applications?

Source: https://habr.com/ru/post/428081/


All Articles