Restate — или как превратить бревно Redux в дерево

    История развития IT намного интереснее любой мыльной оперы, но пересказывать ее мы не будем. Скажем только, что были свидетили принципа «data-driven», адреналинщики с two-way-binding и беспредельщики без принципов и понятий.
    Бог создал людей сильными и слабыми. Сэмюэл Кольт сделал их равными.
    Примерно тоже самое сделали Flux и Redux.

    Была только одна проблема — Redux по сути своей крайне примитивная хреновина, и чтобы с ним хоть как-то работать надо было добавить парочку middleware — thunk, saga, observable и так далее.

    Но эта статья заходит с другой стороны, серьезно так спрашивая про композицию, и, в частности, про компонентную модель. Которой в редаксе — нет.



    Для лучшего понимания проблемы — давайте начнем с азов, и сделаем TODO.

    React вариант


    Реакт все любят за компонентную модель, и вообще за то, что он очень «composable». Можно взять почти любой компонент, обернуть в 10 других и он будет работать так, как тебе надо.(запомните эту фразу)

    // Создадим список TODO
    const TODOs = [todo1, todo2, todo3];
    
    // Передадим приложению как пропс
    const Application = <TodoList todos={TODOs} />
    
    // Определим TodoList, который прокинет пропсы конечному элементу
    const TodoList = ({todos}) => (
      <ol>
        {todos.map( todo => <Todo key={todo.id} {...todo} />
      </ol>
    );
    
    // Ну и сам TODO очень просто
    const Todo = (props) => <div>......</div>
    

    Все хорошо, но встает вопрос о том что делать с событиями, как это все вообще контролировать

    Простой Redux вариант


    Redux решает много проблем, и о них мы говорить сейчас не будем. Я надеюсь код ниже для всех совершенно понятен и очевиден.

    // Создадим стор
    const store = createStore({
       todos: [todo1, todo2, todo3]
    });
    
    // Создадим приложение
    const Application = <Provider store={store}>
     <ConnectedTodoList/>
    </Provider>
    
    // Коннектим компонент к стору. Нам нужны все TODO
    const ConnectedTodoList = connect(
       state => ({todos: state.todos}),
       { onTodoClick }
    )(TodoList)
    
    // Далее все тоже самое
    const TodoList = ({todos}) => (
      <ol>
        {todos.map( todo => <Todo key={todo.id} {...todo} onClick={() => onTodoClick(todo.id)} />
      </ol>
    );
    ....
    

    Это пример плохого redux приложения, и именно так выглядит оригинальный пример из репозитория redux.

    Проблемы тут две:
    — миксуем React и Redux
    — при изменении любого Todo происходит перерендер всего — и списка Todo и каждого из Todo.

    Более правильный Redux


    Правильный вариант от не правильного особо то и не отличается

    // Коннектим компонент к стору. Нам нужны все TODO
    const ConnectedTodoList = connect(
       state => ({todos: getOnlyIds(state.todos)}), <----- изменение вот тут
       { onTodoClick }
    )(TodoList)
    
    const TodoList = ({todos}) => (
      <ol>
        {todos.map( id => <ConnectedTodo key={id} id={id}  /> <----- вот тут
      </ol>
    );
    
    // Конектим Todo к стору
    const ConnectedTodo = connect(
       (state,props) => ({...state.todos[props.id])}), <----- и вот тут
       { onTodoClick: () => dispatch => dispatch(onTodoClick(props.id)) }
    )(Todo)
    ....
    

    Чем это лучше? TodoList не зависит от содержимого конкретного Todo, реагируя только на сам факт наличия. Ну а Todo — сам сходит за данными, и сам свой Id в onTodoClick добавит.

    По сути — добавить «больше» редакса тут уже нельзя. Заодно React механизм прокидывания данных от родителей к детят более не используется. Как результат — никакая композиция более не возможна.

    Какая композиция?


    Хороший пример «проблемы» — это пример Tree-view, опять же из официального репозитория.

    Технически — тот же самый TODO-list, только появляется «вложеность». «Вложености» там правда нет, потому что «стор» совершенно плоский, и любую, самую глубокую ноду можно адресовать по Id. И проблема не в этой структуре данных, а в том, что другую использовать банально не получается!

    Все потому, что Node, встречая для отрисовки дочерних Node рендерит ConnectedNode, а в случае reduxа — все Connected компоненты равны, и конектятся непосредственно к стору.
    И, если компоненты равны, они еще и должны совершить совершенно одинаковое действие — взять элемент массива с известным Id.

    Другими словами — Компоненты работают независимо от своего положение в дереве, и совершенно нельзя взять компонент, обернуть еще в 10, и заставить работать так как вам нужно — он будет работать всегда одинаково. Предсказуемо — конечно же да. Только хотели ли мы это?

    Банально — попробуйте переделать любой TODO список в список-списков, аля Trello — прийдется переписывать примерно все, и уж точно каждый connect.

    И если проблема именно в полном игнирировании reduxом вложености компонентов — возможно именно это и требуется починить. В том смысле, что

    Хватит это терпеть!


    Представим ситуацию, когда родительский компонент может подготовить данные для своего ребенка, так как он считает нужным. Представим ситуацию когда родитель может контролировать все действия, которые совершает его ребенок. И вспомним, что это примерно то, что redux старался «починить», и это никак-нельзя запилить обратно.


    Вот только на самом деле это немного разные вещи. Проблема была в двунаправленности событий, которая порождала каскадные апдейты, которые было практически невозможно контролировать.
    Плюс — 99% примеров продолжают использовать стандартный подход Reacta, который как раз и позволяет правдами или не правдами прокинуть некий секретный Id в ребенка, чтобы он потом смог прочитать данные из стора.

    Просто это можно сделать немного более проще, и правильнее.

    Redux-restate


    Redux-restate (github) — миниатюрная (50 строк*) библиотека, которая принимает один или более стор на вход, и совершает над ними некие операции и выдавает стор на выход.
    Сразу стоит уточнить — колличество сторов не меняется! restate это view в базе данных, или transformation в mobx, или линза, или mapStateToProps(только ToState). Это не «стор».

    На самом деле restate это три отдельный пакета:
    — redux-restate — нижний уровень, делай что хочешь
    — react-redux-restate — обвязка вокруг реакта — получить стор из контекста, и положить обратно (узкий момент)
    — react-redux-focus — упрощенный вариант restate. Для использования в 99% случаев.

    Работает все просто:
    — При «погружении»(создании стейта) можно програмно менять те данные, которые будут доступны для детей
    — При «всплытии»(обработке событий) можно дополнять ивент нужными данными, чтобы правильно сформировать конечную команду.

    «Погружение» — это просто однонаправленное действие, которое берет стейт на вход и давает стейт на выход. При этом очень важно мемоизировать промежуточный результат тем же recompose, так же как это делается в mapStateToProps.

    const composeState = (state, props) => ({
       ...state,
       part: recompose(operationOn(state))
    });
    

    «Всплытие» — однонаправленое действие, которое выполняется когда подчиненный компонет вызывает dispatch. Его смысл — добавить ту «специфичность», что была убрана в момент создания стейта.

     const routeDispatch = (dispatch, event, props) => dispatch({...event, somethingFrom: props.data });
    

    Пример «еще более правильного» redux:

    // Коннектим компонент к стору. Нам нужны все TODO
    const ConnectedTodoList = connect(
       state => ({todos: getOnlyIds(state.todos)}), 
       { onTodoClick }
    )(TodoList)
    
    const TodoList = ({todos}) => (
      <ol>
        {todos.map( id => <RestatedTodo key={id} id={id}  /> <----- изменение вот тут
      </ol>
    );
    
    // Конектим Todo к стору (reactReduxFocus - простой самый простой API для одного стора)
    const RestatedTodo = reactReduxFocus(
       (state, props) => ({todo: state.todos[props.id]}), // оставить в сторе _только_ todo
       (dispatch, event, props) => dispatch({...event, id: props.id}); // добавить в ивент todoID
    )(ConnectedTodo)
    
    // Конектим Todo к стору
    const ConnectedTodo = connect(
       (state,props) => ({...state.todo}), <----- тут стало проще
       { onTodoClick } <---- тут стало проще
    )(Todo)
    ....
    

    Как сайд эффект получаем автоматический areStatesEqual — если вычисленный стейт shallowEqual старому значению — распространение изменения прекращается, что позволяет изолировать различные части приложения друг от друга.

    Пример с деревом теперь можно написать «более» правильно

      const RestatedNode = reactReduxFocus(
         (state, props) => ({
            ...state,
            // перекрываем node данными ребенка
            node: state.node.children[props.nodeId]
         }),
         (dispatch, event, props) => dispatch({
             ...event, 
            // сохраняем в nodeId хлебные крошки пройденого пути
             nodeId:[props.nodeId, ...event.nodeId]
          });
      )(ConnectedNode);
      
      const ConnectedNode = connect(
        state => {
          ...state,
          children: state.node.children,
          leafs: state.node.leafs
       }........);
      )(Node)
    

    И все — вкладывай RestatedNode друг в друга — reactReduxFocus все аккуратно в начале разберет, а потом соберет обратно все хлебные крошки во диспача команды.

    Остается вопрос как же вернуться в нормальному стору, когда «синтетический», «в котором ничего нет» больше не нужен. Ну и становиться нужен reactReduxRestate, который принимает больше одного стора на вход.

      import {createProvider} from 'react-redux'
      import reactReduxFocus from 'react-redux-focus'
      import reactReduxRestate from 'react-redux-restate'
     
     const Provider = createProvider('realStore');
    
    // restate будет читать из 'realStore', а писать - в 'store', с которым работает connect
     const FocusedComponent = reactReduxFocus(..., ..., {
       storageKey: 'realStore'
     })(SomeComponent);
    
     const RestatedComponent = reactReduxRestate({
        // будет произведен коннект с default стором ('store') и 'realStore'
        // после чего можно будет читать данные и из реального стора, и из синтетического. Вдруг там что-то интересное есть?
        realStore: 'realStore'
      },..., ...)(AnotherComponent)
    
     <Provider store={realStore}>
       <FocusedComponent >
         <RestatedComponent /> - подключен сразу к двум "сторам"
         <FocusedComponent /> - подключен только к реальному, несмотря на то, что живет в синтетическом
       </FocusedComponent
     </Provider> 
    

    В общем — исходных код всех трех компонент реально занимает пару десятков строк, но открывает столько вариантов для… композиии.

    В принципе — именно это и требовалось.

    А что, так можно было?


    Restate далеко не пионер в данном вопросе. Например electron-redux занимается «почти» что этим же — соединяет два стора (main и render), один из который не настоящий, да еще и роутит dispatchи из одного в другой.

    Технически того же эффекта можно добиться и с использованием restate, причем с меньшим колличеством костылей, но у restate совершенно нет IPC.

    Или взять «систему опций» Яндекс.Карт. Она вообще не так чтобы очень известная (публична и документированна), но всякий кто хоть раз использовал API Яндекс Карт могли заметить как там красиво «каскадируются» опции — есть цвет полилинии задается на уровне карты, то опция называется geoObjectStrokeColor, а если на уровне геообьекта — strokeColor.

    В общем работает «префексирование», и его можно встретить везде — самый лучший пример работы — в примере настройки карты задается «scrollZoomSpeed», сам же компонент управления будет читать из стора просто «speed».

    Вот только в Яндекс.Картах это префексирование работает снизу вверх — префиксы добавляются от ребенка к родителю, пока не найдется совпадение, что почти исключает возможность купировать апдейты, те изменении какого либо значения нельзя узнать какой компонент каким хитрым образом это значение потом прочитает.

    Restate — попытка скрестить именно систему Яндекс.Карт и redux. Починить и то и другое. Ну и занять долгие вечера каникул.

    -> github.com/thekashey/restate
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 17
      0

      О как! Недавно столкнулся с подобной проблемой и пришлось писать свой велосипед. Но оказывается уже есть более лаконичное решение. Спасибо!

        +1
        Насчет первого случая
         <Todo key={todo.id} todo={todo} onClick={onTodoClick} />
        

        Так не произойдет никакого перерендеринга.
          0
          А они не PureComponent, а обычные StatelessFunctional. А значит перерисуются вместе с родителем, который перерисуется при любом изменении в любом из Todo.

          Вообще вещать побольше connectов исключительно для контроля над распространением изменений — интересная практика.

          Connect — начало любого изменения, потому что дергается непосредственно стором как бы глубоко он не сидел
          Connect — конец любого изменения, потому что SCU не пропустит никакие нежданные изменения прилетевшие сверху.

          Подробнее можно вот тут почитать -> medium.com/@alexandereardon/performance-optimisations-for-react-applications-round-2-2042e5c9af97
          +2
            +1
            до фрактала набрел на такое(это форк с небольшими изменениями): github.com/plandem/react-redux-controller и, честно говоря, с тех пор такой подход мне нравится все еще больше, чем фрактал или то, что в статье. Там хотя бы есть какое-то упрощение кода.
              0

              Спасибо! Контроллеры шикарны!

              0
              Freactal прекрасен, как и многие другие решения. К сожалению требовалось «починить» именно что redux. И именно способом, максимально близким к идеалогии redux.
              По другому идею не продать.
                0
                Ну вы, по сути, на Redux и делаете фрактальный стейт (можно было об этом, собственно, явно в статье указать). Концептуально очень похоже на Fractal или cycle-onionify.
              0

              Отличное решение, должно сильно упростить составные состояния.


              По поводу статьи: не оставляйте необъявленных переменных в примерах, излазился в поисках onTodoClick.

                0
                Может я конечно чего-то не понял, буду признателен если поправите. Но возникло стойкое ощущение, что вы себя просто мучаете зазря. Такие сложности на пустом месте.

                Чисто для сравнения компонент TreeView на основе данных из плоского массива на SvelteJS:

                TreeView.html
                <ul>
                	{{#each leafs as leaf}}
                	<li id="leaf{{leaf.id}}">
                		<h6>{{leaf.title}}</h6>
                		{{#if leaf.childs}}
                		<:Self leafs="{{childs(leaf.childs)}}" />
                		{{/if}}
                	</li>
                	{{/each}}
                </ul>
                
                <script>
                	import tree from './tree.js'
                	
                	export default {
                		data: () => ({
                			leafs: []
                		}),
                		helpers: {
                			childs: childs => tree.apply(null, childs)
                		}	
                	};
                </script>
                


                Вот живой пример поиграться. Можно выставлять любые начальные id-шники дерева в json-объекте справа.

                На полном серьезе не понимаю, почему столь простая задача должна решаться так сложно. Конструктивная критика приветствуется.
                  0
                  Tree был использован как пример «погружения» и «всплытия». Просто как удобный пример.
                    0
                    Понимаю, но по моему опыту статьи про React почему-то делятся на 2 вида: 1) как красно что мы выбрали React, вот какой классный “Hello World”; 2) решаем проблемы React.

                    Создаётся стойкое ощущение что вы себя мучаете только бы чтоб использовать React. Раз с React так много проблем, то может использовать другие решения?
                      +1
                      статьи про React почему-то делятся на 2 вида: 1) как красно что мы выбрали React, вот какой классный “Hello World”; 2) решаем проблемы React.

                      «Есть всего два типа языков программирования: те, на которые люди всё время ругаются, и те, которые никто не использует.» Бьёрн, Страуструп
                        0
                        Видимо он это писал о C++. Не знаю как вы, а я на этом языке писал 4 года и по моему мнению это такое же оправдание, как высказывание Черчилля про демократию.
                  0

                  А чем хуже с точки зрения производительности:


                  const TodoList = ({todos}) => (
                    <ol>
                      {todos.map( item => <ConnectedTodo key={item.id} item={item}  />)}
                    </ol>
                  );

                  чем предложенный вариант по ID?


                  const TodoList = ({todos}) => (
                    <ol>
                      {todos.map( id => <ConnectedTodo key={id} id={id}  />)}
                    </ol>
                  );

                  Во варианте с item мы же так же можем не рендерить если нет изменений (при условии иммутабельности item).

                    0
                    Возможно прямо так сравнивать не совсем корректно, но второй вариант сам добавит «иммутабельность», в виде sCU из connect врапера redux.
                    Если же добавить memoize-state из моей другой статьи, но «контейнер» будет регировать только на изменение колличества todo или ID, полностью игнорируя данные внутри todo элементов, что уже может быть интересно в некоторых случаях.

                    Еще один плюс — данный подход позволяет «разорвать» связь стейта, его использования и редьюсера.
                    Сейчас редьюсер и селектор в неком смысле «симметричны», возможность модифицировать стейт под нужды клиента позволяет это ограничение(это — ограничение) разорвать.
                      0

                      Да, но зато так мы б могли обойтись только массивом todo и никаких индексов из ID не надо. Имхо, идея с ID здравая и элегантная, но это тот случай, когда с водой ребенка выплеснули. Лучше какой-то средний вариант взять, т.е. не спредать item в компонент, но и не закручивать гайки по максимуму с селектором по ID в connect. А чтоб работало совсем по красоте можно еще и обернуть в onlyUpdateForKeys из recompose, чтоб заигнорить лишнее.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое