kefir.atom
v5.5.2
Published
Composable and decomposable reactive state with lenses and Kefir
Downloads
788
Readme
[ ≡ | Motivation | Tutorial | Reference | About ]
This library provides a family of concepts and tools for managing state with lenses and Kefir.
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.
- This means that you can avoid some glitches and unnecessary computations of intermediate states.
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
andItems
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
andItems
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.
≡ Atom(value)
Creates a new atom with the given initial value. For example:
const notEmpty = Atom('initial')
notEmpty.get()
// 'initial'
notEmpty.log()
// [property] <value:current> initial
≡ Atom()
Creates a new atom without an initial value. For example:
const empty = Atom()
empty.get()
// undefined
empty.log()
empty.set('first')
// [property] <value> first
≡ atom.get()
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 Atom
s as undefined
. For example:
const empty = Atom()
const notEmpty = Atom('initial')
const both = new Molecule({empty, notEmpty})
both.get()
// { empty: undefined, notEmpty: 'initial' }
≡ atom.modify(currentValue => newValue)
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)
atom.set(value)
is equivalent to atom.modify(() => value)
and is
provided for convenience.
≡ atom.remove()
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 LensedAtom
s, where the
"removal" will then follow from the semantics of
remove on partial lenses.
≡ atom.view(lens)
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.
≡ holding(() => ...)
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 a :> Property a
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. AbstractMutable
s implicitly skip duplicates using Ramda's
identical
function.
Note that we often abuse terminology and speak of Atom
s when we
should speak of AbstractMutable
s, because Atom
is easier to
pronounce and is more concrete.
≡ Atom a :> AbstractMutable a
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.
≡ LensedAtom a :> AbstractMutable a
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 LensedAtom
s by
calling the view
method of an
AbstractMutable
.
≡ Molecule a :> AbstractMutable (a where AbstractMutable x := x)
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 Molecule
s 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.