In search of a silver bullet: Actors + FRP in React

    Few people now write on Perl, but Larry Walla's famous maxim "Keep simple things easy and hard thing possible" has become the accepted formula for effective technology. It can be interpreted in terms of not only the complexity of the tasks, but also the approach: an ideal technology should, on the one hand, allow the rapid development of medium and small applications (including "write-only"), on the other hand, provide tools for thoughtful development complex applications, where reliability, maintainability and structuredness are put in the first place. Or even, translating into the human plane: to be accessible to the June, and at the same time satisfy the requests of the Signor.


    Popular now Redaks can be criticized on both sides - take at least the fact that writing even an elementary functionality can result in many lines, separated by several files - but we will not go deeper, because this has already been said a lot.


    “It’s as if you keep all the tables in one room and the chairs in the other”
    - Juha Paananen, the creator of the Bacon.js library, about Redax

    The technology, which will be discussed today, is not a silver bullet, but claims to be more consistent with the specified criteria.


    Mrr is a functionally reactive library that professes the principle “everything is a flow”. The main advantages of the functional-reactive approach to mrr are: conciseness, expressiveness of the code, as well as a unified approach for synchronous and asynchronous transformations of data.


    At first glance, this does not sound like a technology that will be readily available to beginners: the concept of flow can be difficult to understand, it is not as common in the frontend, associating mainly with such obscene libraries as Rx. And most importantly, it is not entirely clear how to explain the flows based on the basic action-response-update-DOM scheme. But ... we won't talk about flows in the abstract! Let's talk about more understandable things: events, condition.


    Cooking according to the recipe


    Without getting into the jungle of FRP, we will follow a simple domain formalization scheme:


    • make a list of data that describe the state of the page and will be used in the user interface, as well as their types.
    • make a list of events that occur or are generated by the user on the page, and the types of data that will be transmitted with them
    • make a list of processes that will occur on the page
    • identify interdependencies between them.
    • describe interdependencies using appropriate operators.

    In this case, the knowledge of the library we need only at the very last stage.


    So let's take a simplified example of a web store that has a list of products with pagination and filtering by category, as well as a shopping cart.


    1. The data on the basis of which the interface will be built:


      • product list (array)
      • selected category (string)
      • the number of pages with the goods (number)
      • list of goods that are in the cart (array)
      • current page (number)
      • number of items in the cart (number)

    2. Events (by "events" only instant events are meant. Actions that take place over time - processes - need to be decomposed into separate events):


      • opening page (void)
      • category selection (string)
      • add product to cart (item object)
      • removal of goods from the cart (product id, which is removed)
      • Go to the next page of the product list (number - page number)

    3. Processes: these are actions that start and then can end with different events at once or after some time. In our case, this will be the download of product data from the server, which may entail two events: successful completion and completion with an error.


    4. Interdependencies between events and data. For example, the list of products will depend on the event: "successful loading of the list of products". A "start loading list of products" - from the "open page", "select the current page", "select categories". Make a list of the form [element]: [... dependencies]:


      {
          requestGoods: ['page', 'category', 'pageLoaded'],
          goods: ['requestGoods.success'],
          page: ['goToPage', 'totalPages'],
          totalPages: ['requestGoods.success'],
          cart: ['addToCart', 'removeFromCart'],
          goodsInCart: ['cart'],
          category: ['selectCategory']
      }


    Oh ... but this is almost turned out the code for mrr!



    It remains only to add functions that will describe the relationship. You might have expected that events, data, processes would be different entities in mrr - but no, all this is streams! Our task is only to connect them correctly.


    As we see, we have two types of dependencies: “data” from “event” (for example, page from goToPage) and “data” from “data” (goodsInCart from cart). For each of them there are appropriate approaches.


    The easiest way is with “data from data”: here we simply add a pure function “formula”:


    goodsInCart: [arr => arr.length, 'cart'],

    Each time the cart array is changed, the value of goodsInCart will be recalculated.


    If our data depends on one event, then everything is also quite simple:


    
    category: 'selectCategory',
    /*
    то же саме что 
    category: [a => a, 'selectCategory'],
    */
    goods: [resp => resp.data, 'requestGoods.success'],
    totalPages: [resp => resp.totalPages, 'requestGoods.success'],

    The construction of the form [function, ... threads-arguments] is the basis of mrr. For an intuitive understanding, drawing an analogy with Excel, the flows in mrr are also called cells, and the functions by which they are calculated are formulas.


    If we have data dependent on several events, we must transform their values ​​individually and then merge them into one stream using the merge operator:


    /*
    да, оператор merge - просто строка, это нормально
    */
        page: ['merge', 
            [a => a, 'goToPage'], 
            [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']
        ],
        cart: ['merge', 
            [(item, arr) => [...arr, item], 'addToCart', '-cart'],
            [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
        ],

    In both cases, we refer to the previous value of the cell. To avoid an infinite loop, we refer to the cart and page cells passively (the minus sign in front of the cell name): their values ​​will be substituted into the formula, but if they change, recalculation will not start.


    All streams are either built on the basis of other streams, or emitted from the DOM. But what about the flow "opening page"? Fortunately, it is not necessary to use componentDidMount: there is a special $ start stream in mrr, which signals that the component has been created and mounted.


    "Processes" are calculated asynchronously, while we emit certain events from them, then the "nested" operator will help us:


    requestGoods: ['nested', (cb, page, category) => {
        fetch("...")
        .then(res => cb('success', res))
        .catch(e => cb('error', e));
    }, 'page', 'category', '$start'],

    When using the nested operator, the first argument will be a callback function for issuing certain events. In this case, they will be accessible from the outside via the root cell namespace, for example,


    cb('success', res)

    inside the "requestGoods" formula will entail updating the "requestGoods.success" cell.


    To correctly render the page before our data is calculated, you can specify their initial values:


    {
        goods: [],
        page: 1,
        cart: [],
    },

    Add markup. We create a React component using the withMrr function, which accepts a reactive linking scheme and a render function. In order to "put" a value into a stream, we use the $ function, which creates (and caches) event handlers. Now our completely working application looks like this:


    import { withMrr } from'mrr';
    const App = withMrr({
        $init: {
            goods: [],
            cart: [],
            page: 1,
        },
        requestGoods: ['nested', (cb, page = 1, category = 'all') => {
            fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json())
            .then(res => cb('success', res))
            .catch(e => cb('error', e))
        }, 'page', 'selectCategory', '$start'],
        goods: [res => res.data, 'requestGoods.success'],
        page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']],
        totalPages: [res => res.total_pages, 'requestGoods.success'],
        category: 'selectCategory',
        cart: ['merge', 
            [(item, arr) => [...arr, item], 'addToCart', '-cart'],
            [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
        ],
    }, (state, props, $) => {
        return (<section><h2>Shop</h2><div>
                Category: <selectonChange={$('selectCategory')}><option>All</option><option>Electronics</option><option>Photo</option><option>Cars</option></select></div><ulclassName="goods">
                { state.goods.map((item, i) => { 
                    const cartI = state.cart.findIndex(a => a.id === item.id);
                    return (<likey={i}>
                        { item.name }
                        <div>
                            { cartI === -1 && <buttononClick={$("addToCart", item)}>Add to cart</button> }
                            { cartI !== -1 && <buttononClick={$("removeFromCart", item.id)}>Remove from cart</button> }
                        </div></li>);
                }) }
            </ul><ulclassName="pages">
                { new Array(state.totalPages).fill(true).map((_, p) => {
                    const page = Number(p) + 1;
                    return (
                        <liclassName="page"onClick={$('goToPage', page)} key={p}>
                            { page }
                        </li>
                    );
                }) }
            </ul></section>
        <section>
            <h2>Cart</h2>
            <ul>
                { state.cart.map((item, i) => { 
                    return (<likey={i}>
                        { item.name }
                        <div><buttononClick={$("removeFromCart", item.id)}>Remove from cart</button></div></li>);
                }) }    
            </ul>
        </section>);
    });
    exportdefault App;
    

    Design


    <select onChange={$('selectCategory')}>

    means that when the field is changed, the value will be "pushed through" into the selectCategory stream. But what value? By default, this is event.target.value, but if we need to push something else, we specify it with the second argument, like this:


    <button onClick={$("addToCart", item)}>

    Everything here - and events, and data, and processes - are streams. Triggering an event causes recalculation of data or events depending on it, and so on along the chain. The value of the dependent stream is calculated by the formula that can return the value, or promise (then mrr will wait for its resolve).


    The mrr API is very concise and brief - for most cases we need only 3-4 basic operators, and many things can be done without them. Add an error message when unsuccessfully loading the list of products, which will be shown for one second:


    hideErrorMessage: [() =>newPromise(res => setTimeout(res, 1000)), 'requestGoods.error'],
    errorMessageShown: [
        'merge',
        [() =>true, 'requestGoods.error'],
        [() =>false, 'hideErrorMessage'],
    ],

    Salt pepper, sugar - to taste


    There is in mrr and syntactic sugar, which is optional for development, but can speed it up. For example, the toggle operator:


    errorMessageShown: ['toggle', 'requestGoods.error', [() =>newPromise(res => setTimeout(res, 1000)), 'showErrorMessage']],

    The change in the first argument will set the cell value to true, and the second to false.
    The approach of decomposing the results of an asynchronous task across the successive and successive subcell cells is also so common that a special promise operator can be used for this (automatically eliminating race condition):


        requestGoods: [
            'promise', 
            (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 
            'page', 'selectCategory', '$start'
        ],

    Quite a large functional, just a couple of dozen lines. Our conditional June is satisfied - he managed to write a working code that turned out to be quite compact: all the logic fit in one file and on one screen. But the signor incredulously squints: Eka Nepal ... you can write something like this and use it at / recompose / etc.


    Yes, indeed, you can! The code, of course, is unlikely to be even more compact and structured, but that's not the point. Let's imagine that the project is developing, and we need to divide the functionality into two separate pages: a list of goods and a basket. Moreover, the data basket, obviously, you need to store globally for both pages.


    One approach, one interface


    Here we come to another problem of react-development: the existence of heterogeneous approaches for managing the state locally (within the component) and globally at the level of the entire application. Many, I am sure, faced a dilemma: to implement some kind of logic locally or globally? Or a different situation: it turned out that some piece of local data needs to be saved globally, and you have to rewrite some of the functionality, say, from recomposing to redaks ...


    The opposition is, of course, artificially, and it is not in the mrr: it is equally good, and the main thing is uniform! - suitable for both local and global state management. In general, we don’t need any global state, we just have the opportunity to exchange data between components, thus the state of the root component will be “global”.


    The scheme of our application is now as follows: the root component containing the list of products in the basket, and two nested: products and a basket, with the global component "listening" streams "add to the basket" and "remove from the basket" from the child components.


    const App = withMrr({
        $init: {
            cart: [],
            currentPage: 'goods',
        },
        cart: ['merge', 
            [(item, arr) => [...arr, item], 'addToCart', '-cart'],
            [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
        ],
    }, (state, props, $, connectAs) => {
        return (
            <div>
                <menu>
                    <li onClick={$('currentPage', 'goods')}>Goods</li>
                    <li onClick={$('currentPage', 'cart')}>Cart{ state.cart && state.cart.length ? '(' + state.cart.length + ')' : '' }</li>
                </menu>
                <div>
                    { state.currentPage === 'goods' && <Goods {...connectAs('goods', ['addToCart', 'removeFromCart'], ['cart'])}/> }
                    { state.currentPage === 'cart' && <Cart {...connectAs('cart', { 'removeFromCart': 'remove' }, ['cart'])}/> }
                </div>
            </div>
        );
    })

    const Goods = withMrr({
        $init: {
            goods: [],
            page: 1,
        },
        goods: [res => res.data, 'requestGoods.success'],
        requestGoods: [
            'promise', 
            (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 
            'page', 'selectCategory', '$start'
        ],
        page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']],
        totalPages: [res => res.total, 'requestGoods.success'],
        category: 'selectCategory',
        errorShown: ['toggle', 'requestGoods.error', [cb =>newPromise(res => setTimeout(res, 1000)), 'requestGoods.error']],
    }, (state, props, $) => {
        return (<div>
            ...
        </div>);
    });
    

    const Cart = withMrr({}, (state, props, $) => {
        return (<div><h2>Cart</h2><ul>
                { state.cart.map((item, i) => { 
                    return (<div>
                        { item.name }
                        <div><buttononClick={$('remove', item.id)}>Remove from cart</button></div></div>);
                }) }    
            </ul></div>);
    });
    

    It's amazing how little has changed! We just laid out the streams in the appropriate components and laid "bridges" between them! Connecting components using the mrrConnect function, we specify mapping for downstream and upstream flows:


    connectAs(
        'goods',
        /* вверх */
        ['addToCart', 'removeFromCart'], 
        /* вниз */
        ['cart']
    )

    Here, the addToCart and removeFromCart streams from the child component will go to the parent component, and the cart flow - in the opposite direction. We are not required to use the same stream names - if they do not match, we use mapping:


    connectAs('cart', { 'removeFromCart': 'remove' })

    The remove stream from the child component will be the source for the removeFromCart stream in the parent.


    As you can see, the problem of choosing the location of data storage in the case of mrr is completely removed: you store data where logically caused.


    Here again, it is impossible not to note the lack of Redax: in it you are obliged to save all the data in a single central repository. Even data that may be requested and used by only one single component or its subtree! If we wrote in the “redaks style”, we would also carry out loading and pagination of goods to the global level (in fairness — this approach, thanks to the flexibility of mrr, is also possible and has the right to life, source code )


    However, this is not necessary. Loaded goods are used only in the goods component, therefore, bringing them to the global level, we will only clog up and swell the global state. In addition, we will have to clear outdated data (for example, the pagination page) when the user returns to the product page again. By choosing the right level of data storage, we automatically avoid such problems.


    Another advantage of this approach is that the application logic is combined with the view, which allows us to reuse the individual React components as full-featured widgets, rather than as “silly” templates. Also, by keeping a minimum of information at the global level (ideally, it is only session data) and putting most of the logic into separate components of the pages, we greatly reduce the coherence of the code. Of course, this approach is not applicable everywhere, but there are a large number of tasks where the global state is extremely small and the individual “screens” are almost completely independent of each other: for example, all kinds of admin areas, etc. Unlike Redax, which provokes us to endure everything we need and do not need on a global level, mrr allows us to store data in separate subtrees, encouraging and making possible encapsulation,


    It is worth making a reservation: there is nothing revolutionary in the proposed approach, of course! Components-self-sufficient widgets - were one of the basic approaches used since the very beginning of the js frameworks. The essential difference consists only in the fact that mrr follows the declarative principle: components can only listen to flows of other components, but cannot influence them (both in the "bottom-up" and "top-of-the-line" directions, which differs from flux -approach). Smart components that can only exchange messages with downstream and parent components correspond to the popular, but little-known in front-end development model of actors (the topic of using actors and threads on the frontend is well-chewed in Introduction to reactive programming ).
    Of course, this is far from the canonical implementation of actors, but the essence is precisely this: the role of actors is played by components exchanging messages through mrr streams; a component can (declaratively!) create and delete child component actors thanks to virtual DOM and React: the render function essentially defines the structure of child actors.


    Instead of the standard situation for the React, when we “drop” a certain callback through props from the parent component to the child, we should listen to the flow of the child component from the parent component. The same is true in the opposite direction, from parent to child. For example, you might ask: why transfer the cart data to the Cart component as a stream, if we can, without further ado, just pass it as a props? What is the difference? Indeed, this approach can also be used, but only until there is a need to respond to the change of props. If you have ever used the componentWillReceiveProps method, then you know what it is about. This is a kind of “reactivity for the poor”: you listen to absolutely all the changes in props, determine what has changed, and react.


    In mrr, flows “flow” not only upwards, but also down the hierarchy of components, so that the components can independently respond to changes in state. With this you can use all the power of mrr reactive tools.


    const Cart = withMrr({
        foo: [items => { 
        // что-нибудь делаем
        }, 'cart'],
    }, (state, props, $) => { ... })

    Add a little bureaucracy


    The project is growing, it becomes difficult to keep track of the names of the streams, which are - oh, horror! - stored in rows. Well, we can use constants for stream names, as well as for mrr operators. Now break the application, making a small typo, it becomes more difficult.


    import { withMrr } from'mrr';
    import { merge, toggle, promise } from'mrr/operators';
    import { cell, nested, $start$, passive } from'mrr/cell';
    const goods$ = cell('goods');
    const page$ = cell('page');
    const totalPages$ = cell('totalPages');
    const category$ = cell('category');
    const errorShown$ = cell('errorShown');
    const addToCart$ = cell('addToCart');
    const removeFromCart$ = cell('removeFromCart');
    const selectCategory$ = cell('selectCategory');
    const goToPage$ = cell('goToPage');
    const Goods = withMrr({
        $init: {
            [goods$]: [],
            [page$]: 1,
        },
        [goods$]: [res => res.data, requestGoods$.success],
        [requestGoods$]: promise((page, category) => fetch('https://reqres.in/api/products?page=', page).then(r => r.json()), page$, category$, $start$),
        [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]),
        [totalPages$]: [res => res.total, requestGoods$.success],
        [category$]: selectCategory$,
        [errorShown$]: toggle(requestGoods$.error, [cb =>newPromise(res => setTimeout(res, 1000)), requestGoods$.error]),
    }, ...);
    

    What's in the black box?


    What about testing? The logic described in the mrr component can be easily separated from the template, and then tested.


    Let's make it possible to export the mrr structure from our file separately.


    const GoodsStruct = {
        $init: {
            [goods$]: [],
            [page$]: 1,
        },
        ...
    }
    const Goods = withMrr(GoodsStruct, (state, props, $) => { ... });
    export { GoodsStruct }
    

    and then import it in our tests. With a simple wrapper, we can
    put a value into a stream (as if it were made from a DOM), and then check the values ​​of other threads dependent on it.


    import { simpleWrapper} from'mrr';  
    import { GoodsStruct } from'../src/components/Goods';  
    describe('Testing Goods component', () => {
        it('should update page if it\'s out of limit ', () => {
            const a = simpleWrapper(GoodsStruct);
            a.set('page', 10);
            assert.equal(a.get('page'), 10);
            a.set('requestGoods.success', {data: [], total: 5});
            assert.equal(a.get('page'), 5);
            a.set('requestGoods.success', {data: [], total: 10});
            assert.equal(a.get('page'), 5);
        })
    })

    Glitter and poverty reactivity


    It is worth noting that reactivity is an abstraction of a higher level compared to the "manual" state formation based on events in the Redax. Facilitating development, on the one hand, it creates opportunities to shoot yourself in the foot. Consider this scenario: the user goes to page number 5, then switches the filter "category". We have to load the list of products of the selected category on page 5, but it may turn out that there are only three pages of products of this category. In the case of a "stupid" backend, the algorithm of our actions is as follows:


    • request data page = 5 & category =% category%
    • take from the answer the value of the number of pages
    • if zero entries are returned, request the largest available page

    If we implemented it on Redax, we would have to create one large asynchronous action with the described logic. In the case of reactivity to mrr, there is no need to describe this scenario separately. Everything is already contained in these lines:


        [requestGoods$]: ['nested', (cb, page, category) => {
            fetch('https://reqres.in/api/products?page=', page).then(r => r.json())
            .then(res => cb('success', res))
            .catch(e => cb('error', e))
        }, page$, category$, $start$],
        [totalPages$]: [res => res.total, requestGoods$.success],
        [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]),

    If the new value of totalPages is less than the current page, we will update the page value and thereby initiate a re-execution of the request to the server.
    But if our function returns the same value, it will still be perceived as a change in the flow of a page with the subsequent reclamation of all dependent flows. To avoid this, mrr has a special meaning - skip. Returning it, we signal: no changes have occurred, nothing needs to be updated.


    import { withMrr, skip } from'mrr';
        [requestGoods$]: nested((cb, page, category) => {
            fetch('https://reqres.in/api/products?page=', page).then(r => r.json())
            .then(res => cb('success', res))
            .catch(e => cb('error', e))
        }, page$, category$, $start$),
        [totalPages$]: [res => res.total, requestGoods$.success],
        [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : skip, totalPages$, passive(page$)]),

    Thus, one small error can lead us to an infinite loop: if we return not "skip", but "prev", the page cell will be changed and the second request will be repeated, and so on in a circle. The possibility of such a situation, of course, is not a “vicious flaw” of FRP or mrr, as the possibility of infinite recursion or a cycle does not indicate the viciousness of structural programming ideas. However, it should be understood that mrr still requires some understanding of the mechanism of reactivity. Returning to the famous knife metaphor, mrr is a very sharp knife that improves work efficiency, but can also injure an incompetent employee.


    By the way, debugging mrr is very easy without installing any extensions:


    const GoodsStruct = {
        $init: {
           ...
        },
        $log: true,
        ...
    }

    Simply add $ log: true to the mrr structure, and all changes to the cells will be displayed in the console, so that you can see what is changing.


    Concepts such as passive listening or the value of skip are not specific "crutches": they expand the possibilities of reactivity so that it can be used to easily describe all the logic of an application without resorting to imperative approaches. Similar mechanisms exist, for example, in Rx.js, but their interface is less convenient there. More details on passive listening and various types of statements are discussed in a previous article: Mrr: Total FRP for React


    The source code of the finished example.


    Results


    • thanks to the abstraction of FRP, expressiveness and conciseness on mrr, you can quickly and easily write a lot of functionality with a small amount of code, and it will not be noodles
    • but you don’t need to study complex concepts or dozens of operators: a basic understanding of reactivity is enough, and for beginners there is even a general formalization algorithm
    • and if you suddenly realize that your project will be further developed and developed, you can easily refactor and improve the structure almost without rewriting the code.
    • thanks to the location of the entire state control logic in one place, usually along with the presentation, you can also delve into the code written by someone (tables and chairs finally together!)
    • but the state change logic is easily separable from the presentation and conveniently tested.
    • The mrr slogan also: "Keep your data where you need it!" Choosing the right level of data storage will save you many difficulties.
    • breaking the state of the application into loosely coupled parts reduces the overall connectivity and complexity
    • but be careful, if these parts start to know too much about each other, it may be worthwhile to combine them (the imaginary separation of code only complicates the structure and reduces reliability). Do not fence multi-level interdependent designs!
    • Of the minuses, it can also be noted: the lack of support for typing at the moment, and the principle of TMTOWTDI: the ability to write the same functionality in several different ways, which is why the task of developing optimal and unified approaches to development falls on the shoulders of the developers themselves.

    PS


    The release of the React with hook support was released recently. So, in mrr, you can also work with them, it looks even more elegant than with a class wrapper:


    import useMrr from'mrr/hooks';
    functionFoo(props){
        const [state, $, connectAs] = useMrr(props, {
            $init: {
                counter: 0,
            },
            counter: ['merge', 
                [a => a + 1, '-counter', 'incr'],
                [a => a - 1, '-counter', 'decr']
            ],
        });
        return ( <div>
            Counter: { state.counter }
                <buttononClick={ $('incr') }>increment</button><buttononClick={ $('decr') }>decrement</button><Bar {...connectAs('bar')} /></div> );
    }

    Also popular now: