Skip to content

Composable and decomposable reactive state with lenses and Kefir

License

Notifications You must be signed in to change notification settings

calmm-js/kefir.atom

Repository files navigation

[ | Motivation | Tutorial | Reference | About ]

This library provides a family of concepts and tools for managing state with lenses and Kefir.

npm version Bower version Build Status Code Coverage

Contents

Motivation

Use of state and mutation is often considered error-prone and rightly so. Stateful concepts are inherently difficult, because, unlike stateless concepts, they include the concept of time: state changes over time. When state changes, computations based on state, including copies of state, may become invalid or inconsistent.

Using this library:

  • You can store state in first-class objects called Atoms.

    • This means that program components can declare the state they are interested in as parameters and share state by passing references to state as arguments without copying state.
  • You can declare decomposed first-class views of state using lenses and composed first-class views of state as Molecules.

    • This means that program components can declare precisely the state they are interested in as parameters independently of the storage of state.
  • You get consistent read-write access to state using get and modify operations at any point and through all views.

    • This means that by using views, both decomposed and composed, of state you can avoid copying state and the inconsistency problems associated with such copying.
  • You can declare arbitrary dependent computations using observable combinators from Kefir as AbstractMutables are also Kefir properties.

    • This means that you can declare computations dependent upon state independently of time as such computation are kept consistent as state changes over time.
  • You can mutate state through multiple views and multiple atomic modify operations in a transactional manner by holding event propagation from state changes.

  • You can avoid unnecessary recomputations, because program components can declare precisely the state they are interested in and views of state only propagate actual changes of state.

    • This means that algorithmic efficiency is a feature of this library rather than an afterthought requiring further innovation.

The rest of this README contains a tutorial to managing state using atoms and provides a reference manual for this library.

Tutorial

Let's write the very beginnings of a Shopping Cart UI using atoms with the karet and via the karet.util libraries.

Karet is simple library that allows one to embed Kefir observables into React VDOM. If this tutorial advances at a too fast a pace, then you might want to read a longer introduction to the approach.

This example is actually a stripped down version of the Karet Shopping Cart example that you can see live here.

Counters are not toys!

So, how does one create a Shopping Cart UI?

Well, of course, the first thing is to write the classic counter component:

const Counter = ({count}) => (
  <span>
    <button onClick={U.doModify(count, R.dec)}>-</button>
    {count}
    <button onClick={U.doModify(count, R.inc)}>+</button>
  </span>
)

The Counter component displays a count, which is supposed to refer to state that contains an integer, and buttons labeled - and + that decrement and increment the count using modify.

As you probably know, a counter component such as the above is a typical first example that the documentation of any respectable front-end framework will give you. Until now you may have mistakenly thought that those are just toys.

Component, remove thyself!

The next thing is to write a component that can remove itself:

const Remove = ({removable}) => (
  <button onClick={U.doRemove(removable)}>x</button>
)

The Remove component gives you a button labeled x that calls remove on the removable state given to it.

Pure and stateless

At this point it might be good idea to point out that both the previous Counter component and the above Remove component are referentially transparent aka pure functions. Furthermore, instances of Counter and Remove are stateless. This actually applies to all components in this tutorial and most components in real-world Calmm applications can also be pure functions whose instantiations are stateless. First-class, decomposable, and observable state makes it easy to store state outside of components and make the components themselves pure and stateless.

Lists are simple data structures

Then we write a higher-order component that can display a list of items:

const Items = ({items, Item}) => (
  <div>
    {U.mapElemsWithIds(
      'id',
      (item, key) => (
        <Item {...{key, item}} />
      ),
      items
    )}
  </div>
)

The Items component is given state named items that is supposed to refer to an array of objects. From that array it then produces an unordered list of Item components, passing them an item that corresponds to an element of the items state array.

Items in a cart

We haven't actually written anything shopping cart specific yet. Let's change that by writing a component for cart items:

const count = [L.removable('count'), 'count', L.defaults(0)]

const CartItem = ({item}) => (
  <div>
    <Remove removable={item} />
    <Counter count={U.view(count, item)} />
    {U.view('name', item)}
  </div>
)

The CartItem component is designed to work as Item for the previous Items component. It is a simple component that is given state named item that is supposed to refer to an object containing name and count fields. CartItem uses the previously defined Remove and Counter components. The Remove component is simply passed the item as the removable. The Counter component is given a lensed view of the count. The count lens makes it so that when the count property reaches 0 the whole item is removed.

This is important: By using a simple lens as an adapter, we could plug the previously defined Counter component into the shopping cart state.

If this is the first time you encounter partial lenses, then the definition of count may be difficult to understand, but it is not very complex at all. It works like this. It looks at the incoming object and grabs all the properties as props. It then uses those to return a lens that, when written through, will replace an object of the form {...props, count: 0} with undefined. This way, when the count reaches 0, the whole item gets removed. After working with partial lenses for some time you will be able to write far more interesting lenses.

Items to put into the cart

We are nearly done! We just need one more component for products:

const count = item => [
  L.find(R.whereEq({id: L.get('id', item)})),
  L.defaults(item),
  'count',
  L.defaults(0),
  L.normalize(R.max(0))
]

const ProductItem = cart => ({item}) => (
  <div>
    <Counter count={U.view(count(item), cart)} />
    {U.view('name', item)}
  </div>
)

The ProductItem component is also designed to work as an Item for the previous Items component. Note that ProductItem actually takes two curried arguments. The first argument cart is supposed to refer to cart state. ProductItem also reuses the Counter component. This time we give it another non-trivial lens. The count lens is a parameterized lens that is given an item to put into the cart.

Putting it all together

We now have all the components to put together our shopping cart application. Here is a list of some Finnish delicacies:

const productsData = [
  {id: 1, name: 'Sinertävä lenkki 500g'},
  {id: 2, name: 'Maksainen loota 400g'},
  {id: 3, name: 'Maidon tapainen 0.9l'},
  {id: 4, name: 'Festi moka kaffe 500g'},
  {id: 5, name: 'Niin hyvää ettei 55g'},
  {id: 6, name: 'Suklaa Nipponi 37g'}
]

And, finally, here is our Shop:

const Shop = ({cart, products}) => (
  <div className="panels">
    <div className="panel">
      <h2>Products</h2>
      <Items Item={ProductItem(cart)} items={products} />
    </div>
    <div className="panel">
      <h2>Shopping Cart</h2>
      <Items Item={CartItem} items={cart} />
    </div>
  </div>
)

The Shop above uses the higher-order Items component twice with different Item components and different lists of items.

Summary

For the purposes of this example we are done. Here is a summary:

  • We wrote several components such as Counter, Remove and Items that are not specific to the application in any way.

  • Each component is just one referentially transparent function that takes (possibly reactive variables as) parameters and returns VDOM.

  • We composed components together as VDOM expressions.

  • We used Counter and Items twice in different contexts.

  • When using Counter we used lenses to decompose application specific state to match the interface of the component.

Reference

Typically one only uses the default export

import Atom from 'kefir.atom'

of this library. It provides a convenience function that constructs a new instance of the Atom class.

Creates a new atom with the given initial value. For example:

const notEmpty = Atom('initial')
notEmpty.get()
// 'initial'
notEmpty.log()
// [property] <value:current> initial

Creates a new atom without an initial value. For example:

const empty = Atom()
empty.get()
// undefined
empty.log()
empty.set('first')
// [property] <value> first

Synchronously computes the current value of the atom. For example:

const root = Atom({x: 1})
const x = root.view('x')
x.get()
// 1

Use of get is discouraged: prefer to depend on an atom as you would with ordinary Kefir properties.

When get is called on an AbstractMutable that has a root Atom that does not have a value, get returns the values of those Atoms as undefined. For example:

const empty = Atom()
const notEmpty = Atom('initial')
const both = new Molecule({empty, notEmpty})
both.get()
// { empty: undefined, notEmpty: 'initial' }

Conceptually applies the given function to the current value of the atom and replaces the value of the atom with the new value returned by the function. For example:

const root = Atom({x: 1})
root.modify(({x}) => ({x: x - 1}))
root.get()
// { x: 0 }

This is what happens with the basic Atom implementation. What actually happens is decided by the implementation of AbstractMutable whose modify method is ultimately called. For example, the modify operation of LensedAtom combines the function with its lens and uses the resulting function to modify its source. From the point of view of the caller the end result is the same as with an Atom. For example:

const root = Atom({x: 1})
const x = root.view('x')
x.modify(x => x - 1)
x.get()
// 0
root.get()
// { x: 0 }

atom.set(value) is equivalent to atom.modify(() => value) and is provided for convenience.

atom.remove() is equivalent to atom.set(), which is also equivalent to atom.set(undefined), and is provided for convenience. For example:

const items = Atom(['To be', 'Not to be'])
const second = items.view(1)
second.get()
// 'Not to be'
second.remove()
second.get()
// undefined
items.get()
// [ 'To be' ]

Calling remove on a plain Atom doesn't usually make sense, but remove can be useful with LensedAtoms, where the "removal" will then follow from the semantics of remove on partial lenses.

Creates a new LensedAtom that provides a read-write view with the lens from the original atom. Modifications to the lensed atom are reflected in the original atom and vice verse. For example:

const root = Atom({x: 1})
const x = root.view('x')
x.set(2)
root.get()
// { x: 2 }
root.set({x: 3})
x.get()
// 3

One of the key ideas that makes lensed atoms work is the compositionality of partial lenses. See the equations here: L.compose. Those equations make it possible not just to create lenses via composition (left hand sides of equations), but also to create paths of lensed atoms (right hand sides of equations). More concretely, both the c in

const b = a.view(a_to_b_PLens)
const c = b.view(b_to_c_PLens)

and in

const c = a.view([a_to_b_PLens, b_to_c_PLens])

can be considered equivalent thanks to the compositionality equations of lenses.

Note that, for most intents and purposes, view is a referentially transparent function: it does not create new mutable state—it merely creates a reference to existing mutable state.

There is also a named import holding

import {holding} from 'kefir.atom'

which is function that is given a thunk to call while holding the propagation of events from changes to atoms. The thunk can get, set, remove and modify any number of atoms. After the thunk returns, persisting changes to atoms are propagated. For example:

const xy = Atom({x: 1, y: 2})
const x = xy.view('x')
const y = xy.view('y')
x.log('x')
// x <value:current> 1
y.log('y')
// y <value:current> 2
holding(() => {
  xy.set({x: 2, y: 1})
  x.set(x.get() - 1)
})
// y <value> 1

Concepts

The above diagram illustrates the subtype relationships between the basic concepts

  • Observable,
  • Stream, and
  • Property

of Kefir and the concepts added by this library

The classes AbstractMutable, Atom, LensedAtom and Molecule are provided as named exports:

import {AbstractMutable, Atom, LensedAtom, Molecule} from 'kefir.atom'

Note that the default export is not the same as the named export Atom.

There are use cases where you would want to create new subtypes of AbstractMutable, but it seems unlikely that you should inherit from the other classes.

AbstractMutable is the abstract base class or interface against which most code using atoms is actually written. An AbstractMutable is a Kefir property that also provides for ability to request to modify the value of the property. AbstractMutables implicitly skip duplicates using Ramda's identical function.

Note that we often abuse terminology and speak of Atoms when we should speak of AbstractMutables, because Atom is easier to pronounce and is more concrete.

An Atom is a simple implementation of an AbstractMutable that actually stores state. One can create an Atom directly by explicitly giving an initial value or one can create an Atom without an initial value.

The value stored by an Atom must be treated as an immutable object. Instead of mutating the value stored by an Atom, one mutates the Atom by calling modify, which makes the Atom to refer to the new value.

Note that Atom is not the only possible root implementation of AbstractMutable. For example, it would be possible to implement an AbstractMutable whose state is actually stored in an external database that can be observed and mutated by multiple clients.

A LensedAtom is an implementation of an AbstractMutable that doesn't actually store state, but instead refers to a part, specified using a lens, of another AbstractMutable. One creates LensedAtoms by calling the view method of an AbstractMutable.

A Molecule is a special partial implementation of an AbstractMutable that is constructed from a template of abstract mutables:

const xyA = Atom({x: 1, y: 2})
const xL = xyA.view('x')
const yL = xyA.view('y')
const xyM = new Molecule({x: xL, y: yL})

When read, either as a property or via get, the abstract mutables in the template are replaced by their values:

R.equals(xyM.get(), xyA.get())
// true

When written to, the abstract mutables in the template are written to with matching elements from the written value:

xyM.view('x').set(3)
xL.get()
// 3
yL.get()
// 2

The writes are performed holding event propagation.

It is considered an error, and the effect is unpredictable, if the written value does not match the template, aside from the positions of abstract mutables, of course, which means that write operations, set, remove and modify, on Molecules and lensed atoms created from molecules are only partial.

Also, if the template contains multiple abstract mutables that correspond to the same underlying state, then writing through the template will give unpredictable results.

About

See CHANGELOG.

Implementation trade-offs

The implementations of the concepts provided by this library have been optimized for space at a fairly low level. The good news is that you can use atoms and lensed atoms with impunity. The bad news is that the implementation is tightly bound to the internals of Kefir. Should the internals change, this library will need to be updated as well.

Related work

The term "atom" is borrowed from Clojure and comes from the idea that one only performs "atomic", or race-condition free, operations on individual atoms.

The idea of combining atoms and lenses came from Bacon.Model, which we used initially.

Our use of atoms was initially shaped by a search of way to make it possible to program in ways similar to what could be done using Reagent and (early versions of) WebSharper UI.Next.