Reagent mreframe/reagent: Minimalistic React Mithril for ClojureScript JavaScript

Note: This is a repurposed copy of the original tutorial for Reagent (a React/ClojureScript library)

Introduction to Reagent

mreframe/reagent provides a minimalistic interface for Mithril. It allows you to define efficient React components using nothing but plain JavaScript functions and data, that describe your UI using a Hiccup-like syntax.

The goal of Reagent is to make it possible to define arbitrarily complex UIs using just a couple of basic concepts, and to be fast enough by default that you rarely have to think about performance.

A very basic Reagent component may look something like this:

Examplehide

I am a component!

I have bold and red text.

SourceJS

let simpleComponent = () =>
  ['div',
    ['p', "I am a component!"],
    ['p.someclass',
      "I have ", ['strong', "bold"],
      ['span', {style: {color: 'red'}}, " and red "], "text."]];

You can build new components using other components as building blocks. Like this:

Examplehide

I include simpleComponent.

I am a component!

I have bold and red text.

SourceJS

let simpleParent = () =>
  ['div',
    ['p', "I include simpleComponent."],
    [simpleComponent]];

Data is passed to child components using plain old JS data types. Like this:

Examplehide

Hello, world!

SourceJS

let helloComponent = (name) =>
  ['p', "Hello, ", name, "!"];

let sayHello = () =>
  [helloComponent, "world"];

Note: In the example above, helloComponent might just as well have been called as a normal JS function instead of as a Reagent component. The only difference would have been performance, since ”real” Reagent components are only re-rendered when their data have changed. More advanced components though (see below) must be called with square brackets.

Here is another example that shows items in a list:

Examplehide

Here is a list:
  • Item 0
  • Item 1
  • Item 2

SourceJS

let r = require('mreframe/reagent');
let {range} = require('lodash');

let lister = (items) =>
  ['ul',
    ...items.map(item =>
      r.with({key: item}, ['li', "Item ", item]))];

let listerUser = () =>
  ['div',
    "Here is a list:",
    [lister, range(3)]];

Note: The r.with({key: item}, part above isn’t really necessary in this simple example, but attaching a unique key to every item in a dynamically generated list of components is good practice, and helps Mithril to improve performance for large lists. (Note: use it either for every list item, or for none.) The key can be given either (as in this example) as meta-data, or as a key value in the attributes of a tag. See Mithril documentation for more info.

Managing state in Reagent

The easiest way to manage state in Reagent is to use Reagent’s own version of atom. It works exactly like the basic one, except that it schedules a redraw every time it is changed. Any component that uses a r.atom is automagically re-rendered when its value changes.

Let’s demonstrate that with a simple example:

Examplehide

The atom clickCount has value: 0.

SourceJS

let {reagent: r, atom: {deref, swap}} = require('mreframe');

var clickCount = r.atom(0);

let countingComponent = () =>
  ['div',
    "The atom ", ['code', "clickCount"], " has value: ",
    deref(clickCount), ". ",
    ['input', {type: 'button',  value: "Click me!",
               onclick: () => swap(clickCount, n => n + 1)}]];

Sometimes you may want to maintain state locally in a component. That is easy to do with a r.atom as well.

Here is an example of that, where we call setTimeout every time the component is rendered to update a counter:

Examplehide

Seconds Elapsed: 0

SourceJS

let {reagent: r, atom: {deref, swap}} = require('mreframe');

let timerComponent = () => {
  let secondsElapsed = r.atom(0);
  return () => {
    setTimeout(() => swap(secondsElapsed, n => n + 1), 1000);
    return ['div', "Seconds Elapsed: ", deref(secondsElapsed)];
  };
};

The previous example also uses another feature of Reagent: a component function can return another function, that is used to do the actual rendering. This function is called with the same arguments as the first one.

This allows you to perform some setup of newly created components without resorting to Mithril lifecycle events.

By simply passing a r.atom around you can share state management between components, like this:

Examplehide

The value is now: foo

Change it here:

SourceJS

let {reagent: r, atom: {deref, reset}} = require('mreframe');

let atomInput = (value) =>
  ['input', {type: 'text',  value: deref(value),
             oninput: evt => reset(value, evt.target.value)}];

let sharedState = () => {
  let val = r.atom("foo");
  return () =>
    ['div',
      ['p', "The value is now: ", deref(val)],
      ['p', "Change it here: ", [atomInput, val]]];
};

Essential API

Reagent supports most of Mithril API, but there is really only one entry-point that is necessary for most applications: r.render.

It takes two arguments: a component, and a DOM node. For example, splashing the very first example all over the page would look like this:

SourceJS

let r = require('mreframe/reagent');

let simpleComponent = () =>
  ['div',
    ['p', "I am a component!"],
    ['p.someclass',
      "I have ", ['strong', "bold"],
      ['span', {style: {color: 'red'}}, " and red "], "text."]];

r.render([simpleComponent], document.body);

Putting it all together

Here is a slightly less contrived example: a simple BMI calculator.

Data is kept in a single r.atom: a dict with height, weight and BMI as keys.

Examplehide

BMI calculator

Height: 180cm
Weight: 80kg
BMI: 24 normal

SourceJS

let r = require('mreframe/reagent');
let {deref, swap} = require('mreframe/atom');
let {merge, assoc, dissoc, chain} = require('mreframe/util');

let calcBmi = data => {
  let {height, weight, bmi} = data;
  let h = height / 100;
  return merge(data, (bmi ? {weight: bmi * h * h} : {bmi: weight / (h * h)}));
};

var bmiData = r.atom( calcBmi({height: 180, weight: 80}) );

let slider = (param, value, min, max, invalidates) =>
  ['input', {type: 'range', min, max, value,  // order matters :-(
             style: {width: "100%"},
             oninput: e => {
               let newValue = parseInt(e.target.value);
               swap(bmiData, data => chain(data,
                                           [assoc, param, newValue],
                                           [dissoc, invalidates],
                                           calcBmi));
             }}];

let bmiComponent = () => {
  let {weight, height, bmi} = deref(bmiData);
  let [color, diagnose] = (bmi < 18.5 ? ['orange', "underweight"] :
                           bmi < 25   ? ['inherit', "normal"]     :
                           bmi < 30   ? ['orange', "overweight"]  :
                           ['red', "obese"]);
  return ['div',
           ['h3', "BMI calculator"],
           ['div',
             "Height: ", Math.floor(height), "cm",
             [slider, 'height', height, 100, 220, 'bmi']],
           ['div',
             "Weight: ", Math.floor(weight), "kg",
             [slider, 'weight', weight, 30, 150, 'bmi']],
           ['div',
             "BMI: ", Math.floor(bmi), " ",
             ['span', {style: {color}}, diagnose],
             [slider, 'bmi', bmi, 10, 50, 'weight']]];
};

Performance

Mithril itself is very fast, and so is Reagent. In fact, Reagent will be even faster than plain Mithril a lot of the time, as it automatically prevents rerendering of unchanged components (which are normally the majority).

Mounted components are only re-rendered when their parameters have changed. The change could come from a deref’ed r.atom, the arguments passed to the component or component r.state.

All of these are checked for changes with identical which is basically only a pointer comparison, so the overhead is very low. Dicts passed as arguments to components are compared the same way: they are considered equal if all their entries are identical.

All this means that you simply won’t have to care about performance most of the time. Just define your UI however you like – it will be fast enough.

There are a couple of situations that you might have to care about, though. If you give Reagent a big list of components to render, you might have to supply all of them with a unique key attribute to speed up rendering (see above). Also note that anonymous functions are not, in general, equal to each other even if they represent the same code and closure.

But again, in general you should just trust that Mithril and Reagent will be fast enough. This very page is composed of a single Reagent component with thousands of child components (every single parenthesis etc in the code examples is a separate vnode) and yet the page can be updated many times every second without taxing the browser the slightest.

Incidentally, this page also uses another Mithril trick: the entire page is pre-rendered using Node, and mithril-node-renderer. When it is loaded into the browser, Mithril automatically attaches event-handlers to the already present DOM tree.

Note: Comparing with Mithril perftests (which are mostly testing raw rendering speed), mreframe shows a relative slowdown in the direct performance (by up to 4 times); however, performance of tests involving re-rendering of unchanged components is improved by anywhere from a few to a few dozen times or so depending on the test, due to these components not being recalculated in the first place.

Complete demo

Examplehide

Hello world, it is now

02:25:00
Time color:

SourceJS

let {reagent: r, atom: {deref, reset}} = require('mreframe');

var timer = r.atom(new Date);
var timeColor = r.atom("#f34");


let greeting = (message) =>
  ['h1', message];

let clock = () => {
  let [timeStr] = deref(timer).toTimeString().split(" ");
  return ['div.example-clock', {style: {color: deref(timeColor)}},
           timeStr];
};

let colorInput = () =>
  ['div.color-input',
    "Time color: ",
    ['input', {type: 'text',  value: deref(timeColor),
               oninput: e => reset(timeColor, e.target.value)}]];

let simpleExample = () =>
  ['div',
    [greeting, "Hello world, it is now"],
    [clock],
    [colorInput]];


setInterval(() => reset(timer, new Date), 1000);  // timeUpdater

r.render([simpleExample], document.getElementById('app'));

Todomvc

The obligatory todo list looks roughly like this in Reagent (cheating a little bit by skipping routing and persistence):

Examplehide

Double-click to edit a todo

SourceJS

let {reagent: r, atom: {deref, reset, swap},
     util: {identity, dict, entries, vals, getIn,
            assoc, assocIn, dissoc, updateIn}} = require('mreframe');
let {map, filter} = require('lodash');

var todos   = r.atom({});
var counter = r.atom(0);

let addTodo = (text) => {
  let id = swap(counter, n => n + 1);
  swap(todos, assoc, id, {id,  title: text,  done: false});
};

let toggle = (id) => swap(todos, updateIn, [id, 'done'], it => !it);
let save   = (id, title) => swap(todos, assocIn, [id, 'title'], title);
let remove = (id) => swap(todos, dissoc, id);

let mmap        = (o, f, arg) => dict( f(entries(o), arg) );
let completeAll = v  => swap(todos, mmap, map,    it => assocIn(it, [1, 'done'], v));
let clearDone   = () => swap(todos, mmap, filter, it => !getIn(it, [1, 'done']));


let todoInput = ({title, onsave, onstop}) => {
  let val = r.atom(title || "");
  let stop = () => {reset(val, "");   onstop && onstop()};
  let save = () => {let v = deref(val).trim();
                    v && onsave(v);
                    stop()};
  return ({id, className, placeholder}) =>
    ['input', {type: 'text',  value: deref(val),
               id,  className,  placeholder,
               onblur: save,
               oninput:   e => reset(val, e.target.value),
               onkeydown: e => (e.which === 13 ? save() :
                                e.which === 26 ? stop() :
                                null)}];
};

let todoEdit = r.createClass({  // not quite equivalent to the original code
  componentDidMount: ({dom}) => dom.focus(),
  reagentRender:     params => [todoInput, params],
});

let todoStats = ({filt, active, done}) => {
  let attrsFor = name => ({class: [(name == deref(filt)) && 'selected'],
                           onclick: () => reset(filt, name)});
  return ['div',
           ['span#todo-count',
             ['strong', active], " ", (active == 1 ? "item" : "items"), " left"],
           ['ul#filters',
             ['li', ['a', attrsFor('all'),    "All"]],
             ['li', ['a', attrsFor('active'), "Active"]],
             ['li', ['a', attrsFor('done'),   "Completed"]]],
           (done > 0) &&
             ['button#clear-completed', {onclick: clearDone},
                "Clear completed ", done]];
};

let todoItem = () => {
  let editing = r.atom(false);
  return ({id, done, title}) =>
    ['li', {class: {completed: done,  editing: deref(editing)}},
      ['div.view',
        ['input.toggle', {type: 'checkbox',  checked: done,
                          onchange: () => toggle(id)}],
        ['label', {ondblclick: () => reset(editing, true)}, title],
        ['button.destroy', {onclick: () => remove(id)}]],
      deref(editing) &&
        [todoEdit, {className: 'edit',  title,
                    onsave: it => save(id, it),
                    onstop: () => reset(editing, false)}]];
};

let todoApp = () => {
  let filt = r.atom('all');
  return () => {
    let items = vals( deref(todos) );
    let done = items.filter(it => it.done).length;
    let active = items.length - done;
    return ['div',
             ['section#todoapp',
               ['header#header',
                 ['h1', "todos"],
                 [todoInput, {id:          'new-todo',
                              placeholder: "What needs to be done?",
                              onsave:      addTodo}]],
               (items.length > 0) &&
                 ['div',
                   ['section#main',
                     ['input#toggle-all', {type: 'checkbox', checked: (active === 0),
                                           onchange: () => completeAll(active > 0)}],
                     ['label', {for: 'toggle-all'}, "Mark all as complete"],
                     ['ul#todo-list',
                       ...items.filter(deref(filt) === 'active' ? (it => !it.done) :
                                       deref(filt) === 'done'   ? (it => it.done)  :
                                       identity).map(todo =>
                         r.with({key: todo.id}, [todoItem, todo]))]],
                   ['footer#footer',
                     [todoStats, {active, done, filt}]]]],
             ['footer#info',
               ['p', "Double-click to edit a todo"]]];
  };
};


// init
addTodo("Rename Cloact to Reagent");
addTodo("Add undo demo");
addTodo("Make all rendering async");
addTodo("Allow any arguments to component functions");
completeAll(true);

r.render([todoApp], document.getElementById('app'));