📜 ⬆️ ⬇️

Mrr: Total FRP for React

Mrr is a function-reactive library for React (I apologize for the alleged tautology).

The word "reactivity" usually refers to Rx.js as the FRP reference pattern. However, a series of recent articles on this topic on Habré ( [1] , [2] , [3] ) showed the awkwardness of solutions on Rx, which with simple examples were lost in clarity and simplicity to almost any other approach. Rx is great and powerful, and is perfect for solving problems in which the abstraction of a thread suggests itself (in practice, this is mainly coordination of asynchronous tasks). But would you write, for example, a simple synchronous form validation on Rx? Would he save you time compared to conventional imperative approaches?

mrr is an attempt to prove that an FER can be a convenient and effective solution not only in specific “streaming” problems, but also in the most common routine tasks of the frontend.

Reactive programming is a very powerful abstraction, at the moment it is present in the frontend in two ways:
')

Mrr combines the advantages of these approaches. Unlike Rx.js, mrr has a short API that the user can extend with his add-ons. Instead of dozens of methods and operators, there are four basic operators, instead of Observable (hot and cold), Subject, etc. - one abstraction: stream. Also, mrr lacks some complex concepts that can significantly complicate the code readability, for example, meta streams.

However, mrr is not a “simplified Rx in a new way.” Based on the same basic principles as Rx, mrr claims a bigger niche: managing the global and local (at the component level) state of the application. Although initially the concept of reactive programming was designed to work with asynchronous tasks, mrr successfully uses reactivity approaches for ordinary, synchronous tasks. This is the principle of "total FRP".

Often, when creating an application on a React, several heterogeneous technologies are used: recompose (or soon - hooks) for the component state, Redux / mobx for the global state, Rx through redux-observable (or thunk / saga) to control side effects and coordinate asynchronous tasks in redax. Instead of such a “salad” from different approaches and technologies within one application, with mrr you can use a single technology and paradigm.

The mrr interface is also significantly different from Rx and similar libraries - it is more declarative. Thanks to the reactivity abstraction and the declarative approach, mrr allows writing expressive and concise code. For example, the standard TodoMVC on mrr takes less than 50 lines of code (not counting the JSX template).

But pretty advertising. Whether it turned out to combine the advantages of “light” and “heavy” RP in one bottle is for you to judge, but first I ask you to familiarize yourself with the code examples.

TodoMVC is already pretty awkward, and the example of downloading data about users of Github is too primitive for you to experience the features of the library. We will consider mrr on the example of a conditional application for the purchase of railway tickets. In our UI there will be fields for selecting the starting and ending stations, dates. Then, after sending the data, the list of available trains and places in them will be returned. By selecting a specific train and car type, the user will enter passenger data, and then add tickets to the cart. Go.

We need a form with a choice of stations and dates:



Create fields with autocompletion for entering stations.

import { withMrr } from 'mrr'; const stations = [ '', '', '', ' ', ... ] const Tickets = withMrr({ //    $init: { stationFromOptions: [], stationFromInput: '', }, //   - "" stationFromOptions: [str => stations.filter(s => s.indexOf(str)===0), 'stationFromInput'], }, (state, props, $) => { return (<div> <h3>    </h3> <div>  : <input onChange={ $('stationFromInput') } /> </div> <ul className="stationFromOptions"> { state.stationFromOptions.map(s => <li>{ s }</li>) } </ul> </div>); }); export default Tickets; 

mrr components are created using the withMrr function, which accepts a reactive linking scheme (stream description) and a render function. Render functions are passed to the component props, as well as the state, which is now fully controlled by mrr. It will contain the initial values ​​($ init block) and the values ​​of reactive cells calculated by the formulas.

Now we have two cells (or two streams, which is the same): stationFromInput , the values ​​that get from user input using the $ helper (sending default event.target.value for data elements), and its derived cell stationFromOptions containing an array of suitable stations by name.

The value of stationFromOptions is automatically calculated each time when the parent cell is changed using the function (in the terminology of mrr called " formula " - by analogy with the Excel formula ). The expression syntax mrr is simple: in the first place is the function (or operator), by which the cell value is calculated, then the list of cells on which this cell depends: their values ​​are passed to the function. Such a strange, at first glance, syntax has many advantages, which we will consider later. So far, the mrr logic here resembles the usual approach with computable variables used in Vue, Svelte and other libraries, with the only difference that you can use pure functions.

We implement the substitution of the station selected from the list into the input field. It is also necessary to hide the list of stations after the user clicks on one of them.

 const Tickets = withMrr({ $init: { stationFromOptions: [], stationFromInput: '', }, stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'], stationFrom: ['merge', 'stationFromInput', 'selectStationFrom'], optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'], }, (state, props, $) => { return (<div> <div>  : <input onChange={ $('stationFromInput') } value={ state.stationFrom }/> </div> { state.optionsShown && <ul className="stationFromOptions"> { state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) } </ul> } </div>); }); 

An event handler created with the $ helper in the list of stations will emit values ​​fixed for each option.

Mrr is consistent in its declarative approach, alien to any mutations. After selecting the station, we cannot “force” change the value of the cell. Instead, we create a new stationFrom cell, which, using the merge stream operator merge (an approximate analogue on Rx - combineLatest), will collect the values ​​of two streams: user input ( stationFromInput ) and station selection ( selectStationFrom ).

We have to show the list of options after the user enters something, and hide after selecting one of the options. The optionsShown cell, which will take boolean values ​​depending on changes in other cells, will be responsible for the visibility of the list of options. This is a very common pattern for which there is syntactic sugar - the toggle operator. It sets the cell value to true for any change of the first argument (stream), and to false for the second one.

Add a button to clear the entered text.

 const Tickets = withMrr({ $init: { stationFromOptions: [], stationFromInput: '', }, stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'], clearVal: [a => '', 'clear'], stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', 'clearVal'], optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'], }, (state, props, $) => { return (<div> <div>  : <input onChange={ $('stationFromInput') } value={ state.stationFrom }/> { state.stationFrom && <button onClick={ $('clear') }></button> } </div> { state.optionsShown && <ul className="stationFromOptions"> { state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) } </ul> } </div>); }); 

Now our stationFrom cell, which is responsible for the content of the text in the input field, collects its values ​​not from two, but from three streams. This code can be simplified. The mrr construction of the form [* formula *, * ... cell-arguments *] is similar to the S-expressions in Lisp, and as in Lisp, you can arbitrarily embed such constructions into each other.

Let's get rid of the clear-use cell of clearVal and reduce the code:

  stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', [a => '', 'clear']], 

Programs written in an imperative style can be compared with a poorly organized team, where everyone constantly orders something to each other (hinting at the challenges of methods and mutating changes), with both the heads of subordinates and vice versa. The declarative programs are similar to the opposite utopian picture: the team, where everyone clearly knows how he should act in any situation. In such a team there is no need for orders, everyone is just in place and working in response to what is happening.

Instead of describing all sorts of consequences of an event (read - to make certain mutations), we describe all the cases in which this event may occur, i.e. what value the cell will take when certain changes in other cells. In our small so far example, we described the stationFrom cell and three situations that affect its value. For a programmer who is used to the imperative code, this approach may seem unusual (or even a “crutch”, “perversion”). In fact, it allows saving efforts due to the brevity (and stability) of the code, which we will see in practice.

What about asynchrony? Is it possible to pull up the list of proposed stations with ajax? No problem! In essence, for mrr, it doesn't matter whether the function returns a value or a promise. When returning the promis, the mrr will wait for its resolv and “push through” the received data into the stream.

 stationFromOptions: [str => fetch('/get_stations?str=' + str).then(res => res.toJSON()), 'stationFromInput'], 

It also means that you can use asynchronous functions as formulas. More complex cases (error handling, promise status) will be discussed later.

Functionality for selecting the departure station is ready. There is no sense in duplicating the same thing for the arrival station; it’s worth putting it into a separate component that can be reused. This will be a generalized component of the input with autocompletion, so we will rename the fields and make it so that the function for getting suitable variants is specified in the props.

 const OptionsInput = withMrr(props => ({ $init: { options: [], }, val: ['merge', 'valInput', 'selectOption', [a => '', 'clear']], options: [props.getOptions, 'val'], optionsShown: ['toggle', 'valInput', 'selectOption'], }), (state, props, $) => <div> <div> <input onChange={ $('valInput') } value={ state.val } /> </div> { state.optionsShown && <ul className="options"> { state.options.map(s => <li onClick={ $('selectOption', s) }>{ s }</li>) } </ul> } { state.val && <div className="clear" onClick={ $('clear') }> X </div> } </div>) 

As you can see, you can set the structure of the cells mrr as a function of the props component (however, it will be executed only once - during initialization, and will not respond to the change of props).

Data exchange between components


Now we connect this component in the parent component and see how mrr allows related components to exchange data.

 const getMatchedStations = str => fetch('/get_stations?str=' + str).then(res => res.toJSON()); const Tickets = withMrr({ stationTo: 'selectStationFrom/val', stationFrom: 'selectStationTo/val', }, (state, props, $, connectAs) => { return (<div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div>); }); 

To connect the parent component with the child, we must pass parameters to it using the connectAs function (the fourth argument of the render function). In this case, we specify the name that we want to give the child component. Having thus attached a component, by this name we can refer to its cells. In this case, we are listening to val cells. The opposite is also possible - listen from the child component of the parent cell.

As you can see, here too mrr follows a declarative approach: no onChange callbacks are needed, it is enough for us to specify the name for the child component in the connectAs function, after which we get access to its cells! At the same time, again, due to declarativeness, there is no threat of interference with the work of another component - we cannot “change” anything in it, mutate, we can only “listen” to the data.

Signals and Values


The next stage is the search for suitable trains by the selected parameters. In the imperative approach, we would surely write a worker to send the form onSubmit, which would initiate further actions - ajax request and display of the results. But, as you remember, we can’t "order" anything! We can only create another set of cells derived from form cells. We write another request.

 const getTrains = (from, to, date) => fetch('/get_trains?from=' + from + '&to=' + to + '&date=' + date).then(res => res.toJSON()); const Tickets = withMrr({ stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', results: [getTrains, 'stationFrom', 'stationTo', 'date', 'searchTrains'], }, (state, props, $, connectAs) => { return (<div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div>); }); 

However, such code will not work as expected. The calculation (recalculation of the cell value) is triggered by changing any of the arguments, so the request will be sent, for example, immediately after the selection of the first station, and not just by clicking on “Search”. We need, on the one hand, the stations and the date to be transferred to the arguments of the formula, but on the other hand, not to react to their change. In mrr, there is an elegant mechanism for this, called "passive listening."

  results: [getTrains, '-stationFrom', '-stationTo', '-date', 'searchTrains'], 

Just add a minus in front of the cell name, and voila! Results will now only respond to a change in the searchTrains cell.

In this case, the searchTrains cell acts as a “cell signal”, and the stationFrom cells, etc., as “cell values”. For a cell-signal, only the moment when the value “flows” through it is essential, while what kind of data it will be - all the same: it can be just true, “1” or whatever (in our case it will be DOM Event objects ). For a value cell, it is precisely its value that is important, but the moments of its change are not significant. These two types of cells are not mutually exclusive: many cells are both signals and values. At the syntax level in mrr, these two types of cells do not differ in any way, but the very conceptual understanding of this distinction is very important when writing reactive code.

Splitting streams


The request to search for seats in the train may take some time, so we must show a loader, as well as respond in case of an error. For this default approach with automatic rezolving, there are few promises.

 const Tickets = withMrr({ $init: { results: {}, } stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'], results: ['nested', (cb, query) => { cb({ loading: true, error: null, data: null }); getTrains(query.from, query.to, query.date) .then(res => cb('data', res)) .catch(err => cb('error', err)) .finally(() => cb('loading', false)) }, 'searchQuery'], availableTrains: 'results.data', }, (state, props, $, connectAs) => { return (<div> <div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div> <div> { state.results.loading && <div className="loading">...</div> } { state.results.error && <div className="error"> . ,  .   .</div> } { state.availableTrains && <div className="results"> { state.availableTrains.map((train) => <div />) } </div> } </div> </div>); }); 

The nested operator delivers the “decompose” data on the subcell, for this the first argument is the callback, which can be used to “push” the data into the subcell (one or more). Now we have separate streams that are responsible for the error, the status of the promise and for the data obtained. The nested operator is a very powerful tool and one of the few imperative in mrr (we ourselves indicate in which cells to put the data). While the merge operator merges several streams into one, nested splits the stream into several subflows, thus being its opposite.

The given example is a standard way of working with promises, in mrr it is generalized as a promise operator and allows reducing the code:

  results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'], //     availableTrains: 'results.data', 

The promise operator also ensures that only the most recent promise results are used.



Component for displaying cash places (for the sake of simplicity we will refuse from different types of cars)

 const TrainSeats = withMrr({ selectSeats: [(seatsNumber, { id }) => new Array(Number(seatsNumber)).fill(true).map(() => ({ trainId: id })), '-seatsNumber', '-$props', 'select'], seatsNumber: [() => 0, 'selectSeats'], }, (state, props, $) => <div className="train">  â„–{ props.num } { props.from } - { props.to }.   : { props.seats || 0 } { props.seats && <div>     : <input type="number" onChange={ $('seatsNumber') } value={ state.seatsNumber || 0 } max={ props.seats } /> <button onClick={ $('select') }></button> </div> } </div>); 

To access props in a formula, you can subscribe to the $ props special cell.

 const Tickets = withMrr({ ... selectedSeats: '*/selectSeats', }, (state, props, $, connectAs) => { ... <div className="results"> { state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) } </div> } 

We again use passive listening to pick up the number of selected places by clicking on the "Select" button. We connect each child component with the parent component using the connectAs function. The user can select seats in any of the proposed trains, so we listen to changes in all child components using the mask "*".

But here's the bad luck: the user can add places first in one train, then in another, so that new data will grind past ones. How to “accumulate” stream data? For this, there is a closure operator, which, together with nested and funnel, forms the basis of mrr (all the others are nothing more than syntactic sugar based on these three).

  selectedSeats: ['closure', () => { let seats = []; //     return selectedSeats => { seats = [...seats, selectedSeats]; return seats; } }, '*/selectSeats'], 

When using closure, a closure is created first (on componentDidMount), which returns a formula. It thus has access to variable closures. This allows you to save data between calls in a safe way - without slipping into the abyss of global variables and a shared mutable state. Thus, closure allows you to implement the functionality of Rx operators such as scan and others. However, this method is good for complex cases. If we only need to save the value of one variable, we can simply use the reference to the previous value of the cell using the special name "^":

  selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^'] 

Now the user must enter the first and last name for each selected ticket.

 const SeatDetails = withMrr({}, (state, props, $) => { return (<div> { props.trainId } <input name="name" value={ props.name } onChange={ $('setDetails', e => ['name', e.target.value, props.i]) } /> <input name="surname" value={ props.surname } onChange={ $('setDetails', e => ['surname', e.target.value, props.i]) }/> <a href="#" onClick={ $('removeSeat', props.i) }>X</a> </div>); }) const Tickets = withMrr({ $init: { results: {}, selectedSeats: [], } stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'], results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'], availableTrains: 'results.data', selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^'] }, (state, props, $, connectAs) => { return (<div> <div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div> <div> { state.results.loading && <div className="loading">...</div> } { state.results.error && <div className="error"> . ,  .   .</div> } { state.availableTrains && <div className="results"> { state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) } </div> } { state.selectedSeats.map((seat, i) => <SeatDetails key={i} i={i} { ...seat } {...connectAs('seat' + i)}/>) } </div> </div>); }); 

The selectedSeats cell contains an array of selected locations. As the user enters the name and surname of each ticket, we must change the data in the appropriate elements of the array.

  selectedSeats: [(seats, details, prev) => { // ??? }, '*/selectSeats', '*/setDetails', '^'] 

The standard approach does not suit us: in the formula, we need to know which cell has changed and respond accordingly. One of the forms of the operator merge will help us.

  selectedSeats: ['merge', { '*/selectSeats': (seats, prev) => { return [...prev, ...seats]; }, '*/setDetails': ([field, value, i], prev) => { prev[i][field] = value; return prev; }, '*/removeSeat': (i, prev) => { prev.splice(i, 1); return prev; }, }, '^'/*,       */], 

This is a bit like Redux's reducers, but with a more flexible and powerful syntax. And you can not be afraid to mutate the array, because control over it has only the formula of a single cell, respectively, the parallel changes are excluded (but you should not mutate arrays that are passed as arguments).

Reactive Collections


The pattern, when the cell stores and changes the array, is very common. In this case, all operations with an array are of three types: insert, change, delete. To describe this, there is an elegant " coll " operator. Use it to simplify the calculation of selectedSeats .

It was:

  selectedSeats: ['merge', { '*/selectSeats': (seats, prev) => { return [...prev, ...seats]; }, '*/setDetails': ([field, value, i], prev) => { prev[i][field] = value; return prev; }, '*/removeSeat': (i, prev) => { prev.splice(i, 1); return prev; }, 'addToCart': () => [], }, '^'] 

has become:

  selectedSeats: ['coll', { create: '*/selectSeats', update: '*/setDetails', delete: ['merge', '*/removeSeat', [() => ({}), 'addToCart']] }] 

the data format in the setDetails stream needs to be slightly modified:

  <input name="name" onChange={ $('setDetails', e => [{ name: e.target.value }, props.i]) } /> <input name="surname" onChange={ $('setDetails', e => [{ surname: e.target.value }, props.i]) }/> 

Using the coll operator, we describe three threads that will affect our array. At the same time, the create thread must contain the elements themselves that should be added to the array (usually objects). The delete stream accepts either indices of elements that need to be deleted (both in '* / removeSeat') and masks. The mask {} will remove all elements, and, for example, the mask {name: 'Carl'} would delete all elements with the name Carl. The update stream accepts pairs of values: the change that needs to be made with the element (mask or function), and the index or mask of the elements that need to be changed. For example, [{surname: 'Johnson'}, {}] will set the last name of Johnson to all elements of the array.

The coll operator uses something like an internal query language, making it easier to work with collections and make it more declarative.

The full code of our application is on JsFiddle.

We familiarized with almost all the necessary basic functionality mrr. Quite a significant topic left overboard - global state management, maybe it will be discussed in the following articles. But now you can start using mrr to manage the state inside a component or a group of related components.

findings


What is the power of mrr?


mrr allows you to write applications on React in a functional-reactive style (mrr can be decoded as Make React Reactive). mrr is very expressive - you spend less time writing lines of code.

mrr provides a small set of basic abstractions, which is sufficient for all cases - this article describes almost all the main features and techniques of mrr.There are also tools for expanding this basic set (the ability to create custom operators). You can write beautiful declarative code without reading hundreds of pages of the manual and even without studying the theoretical depths of functional programming - you will hardly have to use, say, monads, since mrr itself is a giant monad separating pure computation from state mutations.

While in other libraries, heterogeneous approaches (imperative using methods and declarative using reactive binding) are often side by side, of which the programmer arbitrarily mixes “salad”, there is a single basic entity in mrr — flow, which promotes homogeneity and uniformity of code. Comfort, convenience, simplicity, saving the programmer's time are the main advantages of mrr (hence another interpretation of mrr as “mr-rr”, that is, murmuring of a cat satisfied with life).

What are the cons?


String programming has both advantages and disadvantages. You will not have the autocomplete name of the cell, and also search for the place where it is defined. On the other hand, in mrr there is always one and only one place where cell behavior is determined, and it is easy to find it with a simple text search, while searching for a place where the value of the Redux field is determined, or, moreover, the field when using native setState may be longer.

Who might be interested?


First of all, adherents of functional programming - to people for whom the advantage of the declarative approach is obvious. Of course, ClojureScript kosher solutions already exist, but they still remain a niche product, while React reigns. If Redux is already used in your project, you can start using mrr to manage the local state, and in the future go to the global one. Even if you do not plan to use new technologies at the moment, you can deal with mrr to "stretch the brain" by looking at familiar tasks in a new light, because mrr is significantly different from the common state management libraries.

Is it already possible to use?


In principle, yes :) The library is young, so far it has been actively used on several projects, but the API of the basic functionality has already been settled, now the work is being done mainly on various lotions (syntactic sugar), designed to further speed up and facilitate development. By the way, in the principles of mrr there is nothing specifically React'ovskogo, it is possible to adapt it for use with any component library (React was chosen due to the lack of its built-in reactivity or the standard library for this).

Thank you for your attention, I will be grateful for the feedback and constructive criticism!

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


All Articles