Mrr — функционально-реактивная библиотека для React'а (извиняюсь за мнимую тавтологию).

При слове «реактивность» обычно вспоминают Rx.js, как эталонный образец FRP. Однако серия последних статей на эту тему на Хабре([1], [2], [3]) показала громоздкость решений на Rx, которые на несложных примерах проигрывали в ясности и простоте почти любому другому подходу. Rx велик и могуч, и прекрасно подходит для решения проблем, в которых абстракция потока напрашивается сама собой (на практике это преимущественно координация асинхронных задач). Но стали бы вы писать, к примеру, простую синхронную валидацию формы на Rx? Сэкономил бы он ваше время, по сравнению с обычными императивными подходами?

mrr — это попытка доказать, что ФРП может быть удобным и эффективным решением не только в специфических «потоковых» проблемах, но и в самых обычных рутинных задачах фронтенда.

Реактивное программирование — очень мощная абстракция, на данный момент на фронтенде она присутствует в двух ипостасях:

  • реактивные переменные (computed variables): просто, надежно, интуитивно понятно, но потенциал РП раскрыт далеко не полностью
  • библиотеки для работы с потоками, такие как Rx, Bacon и т.д.: мощно, но достаточно сложно, сфера практического использования ограничена специфическими задачами.

Mrr совмещает плюсы этих подходов. В отличии от Rх.js, mrr имеет краткий API, который пользователь может расширять своими дополнениями. Вместо десятков методов и операторов — четыре базовых оператора, вместо Observable (горячих и холодных), Subject и т.д. — одна абстракция: поток. Также в mrr отсутствуют некоторые сложные концепции, которые могут существенно усложнить читабельность кода, например, метапотоки.

Однако mrr — это не «упрощенный Rx на новый лад». Отталкиваясь от тех же базовых принципов, что и Rx, mrr претендует на бoльшую нишу: управление глобальным и локальным (на уровне компонента) состоянием приложения. Хотя изначально концепция реактивного программирования предназначалась для работы с асинхронными задачами, mrr с успехом использует подходы реактивности и для обычных, синхронных задач. Это и есть принцип «тотального ФРП».

Часто при создании приложения на Реакте используется несколько разнородных технологий: recompose (или в скором времени — хуки) для состояния компонента, Redux/mobx для глобального состояния, Rx посредством redux-observable (или thunk/saga) для управления сайд-эффектами и координации асинхронных задач в Редаксе. Вместо такого «салата» из разных подходов и технологий внутри одного приложения, с mrr вы можете использовать единую технологию и парадигму.

Интерфейс mrr также существенно отличается от Rx и подобных библиотек — он более декларативен. Благодаря абстракции реактивности и декларативному подходу, mrr позволяет писать выразительный и краткий код. К примеру, стандартное TodoMVC на mrr занимает менее 50 строк кода(не считая JSX шаблона).

Но довольно рекламы. Получилось ли совместить преимущества «легкого» и «тяжелого» RP в одном флаконе — судить вам, но сначала прошу ознакомиться с примерами кода.

TodoMVC уже изрядно набил оскомину, а пример с загрузкой данных о пользователях Github слишком примитивен, чтобы на нем можно было прочувствовать особенности библиотеки. Мы будем рассматривать mrr на примере условного приложения для покупки ж/д билетов. В нашем UI будут поля для выбора начальной и конечной станций, даты. Затем, после отправки данных, будет возвращен список доступных поездов и мест в них. Выбрав конкретный поезд и тип вагона, пользователь введет данные пассажиров, и затем добавит билеты в корзину. Поехали.

Нам нужна форма с выбором станций и даты:



Создадим поля с автодополнением для ввода станций.

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-компоненты создаются с помощью функции withMrr, которая принимает схему реактивных связей (описание потоков) и render-функцию. Render-функции передаются props компонента, а также state, которым теперь полностью управляет mrr. В нем и будут находится начальные(блок $init) и вычисляемые по формулам значения реактивных ячеек.

Сейчас у нас есть две ячейки (либо два потока, что то же самое): stationFromInput, значения в которую попадают из пользовательского ввода с помощью хелпера $ (передающего по умолчанию event.target.value для элементов ввода данных), и производная от нее ячейка stationFromOptions, содержащая массив подходящих по названию станций.

Значение stationFromOptions автоматически вычисляется каждый раз при изменении родительской ячейки с помощью функции (в терминологии mrr называемой "формула" — по аналогии с формулами Экселя). Синтаксис выражений mrr прост: на первом месте идет функция (либо оператор), по которой высчитывается значение ячейки, затем идет список ячеек, от которых данная ячейка зависит: их значения передаются в функцию. Такой странноватый, на первый взгляд, синтаксис имеет много преимуществ, которые мы позже рассмотрим. Пока что логика mrr тут напоминает обычный подход с computable variables, используемый в Vue, Svelte и других библиотеках, с той лишь разницей, что вы можете использовать чистые функции.

Реализуем подстановку выбранной из списка станции в поле ввода. Также необходимо скрывать список станций после того как юзер кликнет по одной из них.

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>);
});

Обработчик событий, созданный с помощью хелпера $, в списке станций будет эмитировать фиксированные для кажной опции значения.

mrr последователен в своем декларативном подходе, чуждом любым мутациям. Мы не можем после выбора станции «принудительно» изменить значение ячейки. Вместо этого мы создаем новую ячейку stationFrom, которая, с помощью оператора объединения потоков merge (приблизительный аналог на Rx — combineLatest), будет собирать значения двух потоков: пользовательского ввода (stationFromInput) и выбора станции (selectStationFrom).

Мы должны показывать список опций после того, как пользователь что-то вводит, и скрывать после того, как он выбрал одну из опций. За видимость списка опций будет отвечать ячейка optionsShown, которая будет принимать булевы значения в зависимости от изменения других ячеек. Это очень распостраненный паттерн, для которого существует синтаксический сахар — оператор toggle. Он устанавливает значение ячейки в true при любом изменении первого аргумента (потока), и в false — второго.

Добавим кнопку для очистки введенного текста.

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>);
});

Теперь наша ячейка stationFrom, отвечающая за содержимое текста в поле ввода, собирает свои значения не с двух, а с трех потоков. Этот код можно упростить. Конструкция mrr вида [*формула*, *… ячейки-аргументы*] аналогична S-выражениям в Lisp'е, и как и в Лиспе, вы можете произвольно вкладывать такие конструкции друг в друга.

Давайте избавимся от малополезной ячейки clearVal и сократим код:

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

Программы, написанные в императивном стиле, можна сравнить с плохо организованным коллективом, где все постоянно что-то приказывают друг другу (намекаю на вызовы методов и мутирующие изменения), при чем как руководители подчиненным, так и наоборот. Декларативные же программы похожи на противоположную утопическую картину: коллектив, где каждый четко знает, как он должен действовать в любой ситуации. В таком коллективе нет нужды в приказах, все просто находятся на своих местах и работают, реагируя на происходящее.

Вместо того, чтобы описывать всевозможные последствия какого-то события (читай — делать те или иные мутации), мы описываем все случаи, при которых данное событие может наступить, т.е. какие значение примет ячейка при тех или иных изменениях других ячеек. В нашем небольшом пока примере мы описали ячейку stationFrom и три ситуации, которые влияют на ее значение. Для привыкшего к императивному коду программиста такой подход может показаться непривычным (или даже «костылем», «извращением»). На самом деле он позволяет экономить усилия за счет краткости (и стабильности) кода, в чем мы убедимся на практике.

А что насчет асинхронности? Можно ли подтягивать список предложенных станций ajax'ом? Без проблем! В сущности, для mrr все равно, вернет ли функция значение или промис. При возврате промиса mrr дождется его resolv'а и «протолкнет» полученные данные в поток.

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

Это также означает, что вы можете использовать асинхронные функции в качестве формул. Более сложные случаи (обработка ошибок, статус промиса) мы рассмотрим позже.

Функционал для выбора станции отправления готов. Дублировать то же самое для станции прибытия нет смысла, стоит вынести это в отдельный компонент, который можно повторно использовать. Это будет обобщенный компонент инпута с автодополнением, поэтому мы переименуем поля и сделаем, чтобы функция получения подходящих вариантов задавалась в 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>)

Как видите, вы можете задать структуру ячеек mrr как функцию от props компонента (однако выполнится она лишь один раз — при инициализации, и не будет реагировать на изменение props).

Обмен данными между компонентами


Теперь подключим этот компонент в родительском компоненте и посмотрим, как mrr позволяет родственным компонентам обмениваться данными.

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>);
});

Чтобы связать родительский компонент с дочерним, мы должны передать ему параметры с помощью функции connectAs (четвёртый аргумент render-функции). При этом мы указываем имя, которое хотим дать дочернему компоненту. Присоединив таким образом компонент, по этому имени мы можем обращаться к его ячейкам. В данном случае, мы слушаем ячейки val. Возможно и обратное — слушать из дочернего компонента ячейки родительского.

Как видите, и здесь mrr следует декларативному подходу: не нужно никаких onChange колбэков, нам достаточно указать имя для дочернего компонента в функции connectAs, после чего мы получаем доступ к его ячейкам! При этом, опять же вследствии декларативности, угрозы вмешательства в работу другого компонента нет — мы не имеем возможности ничего в нем «изменить», мутировать, можно лишь «слушать» данные.

Сигналы и значения


Следующий этап — поиск подходящих поездов по выбранным параметрам. В императивном подходе мы бы наверняка написали некий обрабочик на отправку формы onSubmit, который бы инициировал дальнейшие действия — ajax запрос и отображение полученных результатов. Но, как вы помните, нам нельзя ничего «приказывать»! Мы можем только создать еще набор ячеек, производных от ячеек формы. Напишем еще один запрос.

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>);
});

Однако, такой код не будет работать как ожидалось. Калькуляция (пересчет значения ячейки) запускается при изменении любого из аргументов, поэтому запрос будет отправляться, например, сразу после выбора первой станции, а не только по клику на «Поиск». Нам нужно, с одной стороны, чтобы станции и дата передавались в аргументы формулы, но с другой стороны, не реагировать на их изменение. В mrr для этого существует элегантный механизм, называемый «пассивное слушание».

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

Просто добавляем минус перед именем ячейки, и вуаля! Теперь results будет реагировать только на изменение ячейки searchTrains.

В этом случае ячейка searchTrains выступает как «ячейка-сигнал», а ячейки stationFrom и др. — как «ячейки-значения». Для ячейки-сигнала существенным является только момент, когда по ней «протекает» значние, при этом, какие именно данные это будут — все равно: это может быть просто true, «1» или что угодно (в нашем случае это будут объекты DOM Event). Для ячейки-значения важным есть именно ее значение, но при этом моменты ее изменения несущественны. Эти два типа ячеек не взаимоисключающи: многи ячейки являются и сигналами, и значениями. На уровне синтаксиса в mrr эти два вида ячеек никак не различаются, но само концептуальное понимание такого различия очень важно при написании реактивного кода.

Расщепление потоков


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

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>);
});

Оператор nested позвозяет «раскладывать» данные по подъячейкам, для этого первым аргументом в формулу передается callback, с помощью которого можно «протолкнуть» данные в подъячейку (одну или несколько). Теперь у нас есть отдельные потоки, которые отвечают за ошибку, статус промиса и за полученные данные. Оператор nested — очень мощный инструмент и один из немногих императивных в mrr (мы сами указываем, в какие ячейки класть данные). В то время как оператор merge объединяет несколько потоков в один, nested расщепляет поток на несколько подпотоков, таким образом являясь его противоположностью.

Приведенный пример является стандартным способом работы с промисами, в mrr он обобщен в виде оператора promise и позволяет сократить код:

  results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'],
  // используем один из подпотоков
  availableTrains: 'results.data',

Также оператор promise следит за тем, чтобы использовались результаты только самого последнего промиса.



Компонент для отображения наличных мест (откажемся для простоты от разных типов вагонов)

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>);

Чтобы обращаться к props в формуле, можно подписаться на специальную ячейку $props.

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

Мы опять используем пассивное слушание чтобы подхватить количество выбранных мест при нажатии на кнопку «Выбрать». Каждый дочерний компонент мы связываем с родительским с помощью функции connectAs. Пользователь может выбрать места в любом из предложенных поездов, поэтому мы слушаем изменения во всех дочерних компонентах с помощью маски "*".

Но вот незадача: пользователь может добавить места сначала в одном поезде, потом в другом, так что новые данные перетрут предыдущие. Как «аккумулировать» данные потока? Для этого существует оператор closure, который вместе с nested и funnel составляет основу mrr (все остальные — не более чем синтаксический сахар на основе этих трех).

  selectedSeats: ['closure', () => {
    let seats = [];
      // эта функция станет формулой
      return selectedSeats => {
        seats = [...seats, selectedSeats];
        return seats;
      }
  }, '*/selectSeats'],

При использовании closure сначала (на componentDidMount) создается замыкание, которое возвращает формулу. Она таким образом имеет доступ к переменным замыкания. Это позволяет сохранять данные между вызовами безопасным способом — не скатываясь в пучину глобальных переменных и shared mutable state. Таким образом, closure позволяет реализовать функциональность таких операторов Rx, как scan и прочие. Однако этот способ хорош для сложных случаев. Если же нам нужно только сохранять значение одной переменной, мы можем просто использовать ссылку на предыдущее значение ячейки с помощью специального имени "^":

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

Теперь пользователь должен ввести имя и фамилию для каждого выбранного билета.

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>);
});


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

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

Стандартный подход нам не подойдет: в формуле мы должны знать, какая именно ячейка изменилась и реагировать соответствующе. Нам поможет одна из форм оператора merge.

  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;
    },
  }, '^'/*, здесь также могут быть любые другие аргументы*/],

Это немного напоминает редьюсеры Redux'а, но с более гибким и мощным синтаксисом. И можно не бояться мутировать массив, ведь контроль над ним имеет только формула одной ячейки, соответственно параллельные изменения исключены (а вот мутировать массивы, которые передаются в качестве аргументов, конечно же не стоит).

Реактивные коллекции


Паттерн, когда ячейка хранит в себе и изменят массив, очень распостранен. Все операции с массивом при этом бывают трех типов: вставка, изменение, удаление. Чтобы описать это, существует элегантный оператор "coll". Используем его чтобы упростить вычисление selectedSeats.

Было:

  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': () => [],
  }, '^']

стало:

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

при этом формат данных в потоке setDetails нужно немного изменить:

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

Используя оператор coll, мы описываем три потока, которые будут влиять на наш массив. При этом поток create должен содержать сами элементы, которые должны быть добавлены в массив (обычно — объекты). Поток delete принимает либо индексы элементов, которые нужно удалить (как в '*/removeSeat'), так и маски. Маска {} удалит все элементы, а, к примеру, маска { name: 'Carl' } удалила бы все элементы с именем Carl. Поток update принимает пары значений: изменение, которое нужно сделать с элементом (маска либо функция), и индекс или маску элементов, которые нужно изменить. Например, [{ surname: 'Johnson' }, {}] установит фамилию Johnson всем элементам массива.

Оператор coll использует что-то вроде внутреннего языка запросов, позволяя упростить работу с коллекциями и сделать ее более декларативной.

Полный код нашего приложения на JsFiddle.

Мы ознакомились практически со всем необходимым базовым функционалом mrr. Довольно существенная тема, которая осталась за бортом — глобальное управление состоянием, возможно она будет рассмотрена в следующих статьях. Но уже сейчас вы можете начать использовать mrr для управления состоянием внутри компонента или группы родственных компонентов.

Выводы


В чем сила mrr?


mrr позволяет писать приложения на React в функционально-реактивном стиле (mrr можно расшифровать как Make React Reactive). mrr очень выразителен — вы тратите меньше времени на написание строчек кода.

mrr предоставляет небольшой набор базовых абстракций, которого достаточно для всех случаев — в этой статье описаны практически все основные фичи и приемы mrr. Также есть инструменты для расширения этого базового набора (возможность создавать кастомные операторы). Вы сможете писать красивый декларативный код, не читая сотни страниц мануала и даже не изучая теоретические глубины функционального программирования — врядли вам придется использовать, скажем, монады, т.к. mrr сам представляет собой гигантскую монаду, отделяющую чистые вычисления от мутаций состояния.

В то время как в других библиотеках зачастую соседствуют разнородные подходы (императивный с помощью методов и декларативный с помощью реактивных биндингов), из которых программист произвольно смешивает «салат», в mrr существует единая базовая сущность — поток, что способствует гомогенности и единообразию кода. Комфорт, удобство, простота, экономия времени программиста — основные преимущества mrr (отсюда еще одна расшифровка mrr как «мр-р-р», то есть мурчание удовлетворенного жизнью кота).

Каковы минусы?


Программирование «строками» имеет как свои преимущества, так и недостатки. У вас не будет работать автокомплит имени ячейки, а также поиск места, где она определена. С другой стороны, в mrr всегда есть одно и только одно место, где определяется поведение ячейки, и его несложно найти простым текстовым поиском, в то время как поиск места, где определяется значение поля Redux стора, или тем более поля state при использовании нативного setState, может быть более долгим.

Кому это может быть интересно?


В первую очередь, адептам функционального программирования — людям, для которых преимущество декларативного подхода очевидно. Конечно, уже существуют кошерные решения на ClojureScript, но все же они остаются нишевым продуктом, в то время как React правит бал. Если в вашем проекте уже используется Redux, вы можете начать использовать mrr для управления локальным состоянием, и в перспективе перейти к глобальному. Даже если вы не планируете использование новых технологий в данный момент, вы можете разобраться с mrr чтобы «размять мозг», взглянув на привычные задачи в новом свете, ведь mrr существенно отличается от распостраненных библиотек управления состоянием.

Это уже можно использовать?


В принципе, да :) Библиотека молодая, пока что активно использовалась на нескольких проектах, но API базового функционала уже устоялось, сейчас работа ведется преимущественно над разными примочками (синтаксический сахар), призванными еще больше ускорить и облегчить разработку. К слову, в самих принципах mrr нет ничего специфично React'овского, его возможно адаптировать для использования с любой компонетной библиотекой (React был выбран в силу отсутствия у него встроенной реактивности или общепринятой библиотеки для этого).

Спасибо за внимание, буду благодарен за отзывы и конструктивную критику!

Комментарии (12)


  1. JustDont
    24.11.2018 14:41
    +1

    У вас не будет работать автокомплит имени ячейки, а также поиск места, где она определена.

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


    1. mikolalex Автор
      24.11.2018 19:22

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

      Навигация по имени переменных — это хорошо для подключаемых модулей. Т.е, допустим, вы видите какую-то переменную, которая определена в другом модуле, кликаете по ней и переходите в другой файл к ее определению. Отлично. Но mrr — это средство управление состоянием, и состояние обычно — это не набор отдельных переменных. Если взять, к примеру, Редакс, то там состояние — это поля объекта, который формируется сложным образом. Чтобы понять, почему поле объекта приобрело то или иное значение — нужно, по сути, найти соответствующий редьюсер и просмотреть все его case'ы.

      В mrr значение поля определяется явно на основе других потоков. Если это поток с этого же компонента, то вы его легко и быстро найдете — в этом же файле. Если это поток извне — то тут уже нужно искать по имени потока во всех компонентах. Найти определение внешнего потока со 100% вероятностью нельзя, как нельзя найти «определение» props вашего компонента: это невозможно, т.к. компонент может подключаться разными родительскими компонентами.


      1. JustDont
        24.11.2018 20:07

        А как с сообщениями об ошибках? Вот кто-нибудь промахнулся в названии ячейки — что он в консоли увидит?

        ЗЫ: Концептуально мне ваша штука очень нравится — но я думаю, она любому понравится, кто сталкивался «лбом» с RxJS на формочках — когда код в общем-то очень логичный и прекрасно работает, но настолько перегружен RxJS-specific заморочками и терминами, что люди без опыта возни конкретно с RxJS всегда слегка так офигевают, и быстро погрузить в тему их бывает непросто.
        Но точно так же у меня много опыта с Require.js и прекрасными случаями опечаток и разных проблем с именами модулей, приводящими к обрушению всего на свете, и причём частенько еще и так, что определить место конкретного обрушения было непросто.


        1. mikolalex Автор
          25.11.2018 11:28

          Да, как удачно подметили в комментарии ниже, можно использовать константы.


      1. funca
        24.11.2018 23:35

        Интересно, есть ли возможность избавиться от строковых констант в интерфейсе? В том же редаксе рекомендуют давать типам имена с помощью const, которые нормально поддерживаются инструментарием.


  1. funca
    24.11.2018 23:22

    Наблюдая за винегретом синтаксиса и модой на транспиллеры кажется, что Javascript был бы лучше, будь он изначально Lisp. Когда код как данные и с ними можно делать что угодно не нарушая ни каких правил. :)


    1. mikolalex Автор
      25.11.2018 10:15
      +1

      Согласен на 100%! То, о чем вы говорите, называется гомоиконность, и ее действительно не хватает джаваскрипту. Если бы она была, инфраструктура джс была бы менее монструозной. А mrr выглядел бы изящнее и органичней)


  1. XimikS
    25.11.2018 01:07

    Есть гораздо более простой github.com/solkimicreb/react-easy-state (правда ограничен поддержкой ES6 Proxy)

    import React from 'react'
    import { store, view } from 'react-easy-state'
    
    const counter = store({
      num: 0,
      incr: () => counter.num++
    })
    
    export default view(() => <button onClick={counter.incr}>{counter.num}</button>)


    1. mikolalex Автор
      25.11.2018 10:13

      Это все покрывается функционалом хуков, см. useState.
      Но mrr — это для взаимодействия не только внутри компонента, но и между разными компонентами.
      Насчет простоты: попробуйте написать хотя бы обычное TodoMVC на react-easy-state или хуках, и увидите, чем обернется эта простота(значительно большим количеством кода, как минимум).


  1. dagen
    25.11.2018 01:21
    +1

    Мне нравится эта идея, но есть некоторые вопросы:
    1. Правильно ли я понял, что withMrr — это не HOC?
    2. Связность между компонентами. Вы же понимаете, что всякие flux/redux/mobx придумывают ради того, чтобы не хранить логику и состояние, привязанное к компонентам. Но в примерах вы поощряете код, дающий высокую связность. Да, это лучше для маленьких приложений
    3. Server-side render. В замыкании index.js, откуда экспортится `withMrr`, лежит внутреннее состояние библиотеки, т.е. стейтфул. Как это будет дружить с stateless сервером пререндера, который масштабируется горизонтально и который не должен никак знать про общее состояние или про других параллельно обрабатываемых пользователях?
    4. Тайпинги для flow/ts? Строки — это не только проблемы взаимодействия с IDE, но и проблемы с типизацией.
    5. Как это всё покрывать юнит-тестами? Просто всё, что есть в withMrr как чёрный ящик?


    1. mikolalex Автор
      25.11.2018 10:47

      Правильно ли я понял, что withMrr — это не HOC?


      Да, и это кстати, забыл упомянуть, еще одно преимущество мрр. Так как на выходе вы получаете чистое дерево компонентов без многочисленных оберток.

      Вы же понимаете, что всякие flux/redux/mobx придумывают ради того, чтобы не хранить логику и состояние, привязанное к компонентам.


      Flux-redux, как мне кажется, приучил людей бездумно пихать все что можно в глобальный стейт. Чтобы различать, что нужно хранить глобально, а что нет, я вывел такое простое правило: кладите в глобальное состояние только те данные, которые будут нужны более чем одному компоненту. Например, состояние формы, до тех пор пока она не отправлена(валидация, значения полей) — это чисто локальное состояние, которое не стоит хранить в Редаксе вообще (даже сам Ден Абрамов где-то писал об этом). Но тем не менее, существует много решений, где все это обрабатывается через глобальный стор редакса.

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

      Server-side render. В замыкании index.js, откуда экспортится `withMrr`, лежит внутреннее состояние библиотеки, т.е. стейтфул


      Нет. Если заглянуть во внутренности мрр, логика примерно такая: withMrr создает stateful React компонент со всеми методами мрр и состоянием, используя для рендера ту функцию, которую вы передали. Все данные хранятся внутри компонента. index.js ничего не запоминает.

      Тайпинги для flow/ts?


      Пока никак, и даже путей к решению проблемы пока не вижу, но пытаюсь найти.

      Как это всё покрывать юнит-тестами?


      Пока что, тестировать логику мрр придется вместе с компонентом: берете, например, enzyme, монтируете компонент, затем:
      1) записываем какие-то данные в поток
      2) проверяем данные в производном потоке
      Примерно вот так: github.com/mikolalex/mrr/blob/master/spec/base.spec.js#L140


      1. funca
        25.11.2018 23:35

        Реактивность удобна когда важна череда событий, чтобы придти в нужное состояние. У Rxjs есть приятный marbles testing. Но для интерфейса интереснее стабильные состояния. Реактивность тащит за собой лишиние церемонии в сетап тестов. В примере это хорошо видно. Может стоит попытаться вынести максимум jsx в dumb компоненты, которые тестить в разы легче?