@thi.ng/atom
About
Clojure inspired mutable wrappers for (usually) immutable values, with infrastructure support for:
- watches
- derived view subscriptions
- cursors (direct R/W access to nested values)
- undo/redo history
- composable interceptor & side effect based event handling
Together these types act as building blocks for various application state
handling patterns, specifically aimed (though not exclusively) at the concept
of using a nested, immutable, centralized atom as single source of truth within
an application.
Status
Stable, used in production and in active development.
Installation
yarn add @thi.ng/atom
Usage examples
Several projects in the
/examples
directory make heavy use of this library.
Atom
An Atom
is a mutable wrapper for immutable values. The wrapped value can be
obtained via deref()
, replaced via reset()
and updated using swap()
. An
atom too supports the concept of watches, essentially onchange
event handlers
which are called from reset
/swap
and receive both the old and new atom
values.
import * as atom from "@thi.ng/atom";
const a = new atom.Atom(23);
a.deref();
a.addWatch("foo", (id, prev, curr) => console.log(`${id}: ${prev} -> ${curr}`));
const add = (x, y) => x + y;
a.swap(add, 1);
a.reset(42);
Cursor
Cursors provide direct & immutable access to a nested value within a structured
atom. The path to the desired value must be provided when the cursor is created
and cannot be changed later. The path is then compiled into a getter and
setter to
allow cursors to be used like atoms and update the parent state in an immutable
manner (i.e. producing an optimized copy with structural sharing of the
original (as much as possible)) - see further details below.
It's important to remember that cursors also cause their parent state (atom or
another cursor) to reflect their updated local state. I.e. any change to a
cursor's value propagates up the hierarchy of parent states.
a = new atom.Atom({a: {b: {c: 1}}})
b=new atom.Cursor(a, "a.b")
c=new atom.Cursor(b, "c")
c.reset(2);
b.deref();
a.deref();
For that reason, it's recommended to design the overall data layout rather wide
than deep (my personal limit is 3-4 levels) to minimize the length of the
propagation chain and maximize structural sharing.
main = new atom.Atom({ a: { b: { c: 23 }, d: { e: 42 } }, f: 66 });
cursor = new atom.Cursor(main, "a.b.c");
cursor = new atom.Cursor(main, ["a","b","c"]);
cursor = new atom.Cursor(
main,
(s) => s.a.b.c,
(s, x) => ({...s, a: {...s.a, b: {...s.a.b, c: x}}})
);
cursor.addWatch("foo", console.log);
cursor.deref()
cursor.swap(x => x + 1);
main.deref()
Derived views
Whereas cursors provide read/write access to nested key paths within a state atom, there are many situations when one only requires read access and the ability to (optionally) produce transformed versions of such a value. The View
type provides exactly this functionality:
db = new atom.Atom({a: 1, b: {c: 2}});
viewA = db.addView("a");
viewC = db.addView("b.c", (x) => x * 10);
viewA.deref()
viewC.deref()
db.swap((state) => atom.setIn(state, "b.c", 3))
viewA.changed()
viewC.changed()
viewC.deref()
viewC.deref()
viewA.release()
viewC.release()
Atoms & views are useful tools for keeping state outside UI components. Here's
an example of a tiny
@thi.ng/hdom
web app, demonstrating how to use derived views to switch the UI for different
application states / modules.
Note: The constrained nature of this next example doesn't really do justice to
the powerful nature of the approach. Also stylistically, in a larger app we'd
want to avoid the use of global variables (apart from db
) as done here...
For a more advanced / realworld usage pattern, check the event handling section
and examples further below.
This example is also available in standalone form:
Source | Live demo
import { Atom, setIn } from "@thi.ng/atom";
import { start } from "@thi.ng/hdom";
const db = new Atom({ state: "login" });
const appState = db.addView<string>("state");
const error = db.addView<string>("error");
const user = db.addView<string>(
"user.name",
(x) => x ? x.charAt(0).toUpperCase() + x.substr(1) : null
);
const setValue = (path, val) => db.swap((state) => setIn(state, path, val));
const setState = (s) => setValue(appState.path, s);
const setError = (err) => setValue(error.path, err);
const setUser = (e) => setValue(user.path, e.target.value);
const loginUser = () => {
if (user.deref() && user.deref().toLowerCase() === "admin") {
setError(null);
setState("main");
} else {
setError("sorry, wrong username (try 'admin')");
}
};
const logoutUser = () => {
setValue(user.path, null);
setState("logout");
};
const uiViews = {
login: () =>
["div#login",
["h1", "Login"],
error.deref() ? ["div.error", error.deref()] : undefined,
["input", { type: "text", onchange: setUser }],
["button", { onclick: loginUser }, "Login"]
],
logout: () =>
["div#logout",
["h1", "Good bye"],
"You've been logged out. ",
["a",
{ href: "#", onclick: () => setState("login") },
"Log back in?"
]
],
main: () =>
["div#main",
["h1", `Welcome, ${user.deref()}!`],
["div", "Current app state:"],
["div",
["textarea",
{ cols: 40, rows: 10 },
JSON.stringify(db.deref(), null, 2)]],
["button", { onclick: logoutUser }, "Logout"]
]
};
const currView = db.addView(
appState.path,
(state) =>
uiViews[state] ||
["div", ["h1", `No component for state: ${state}`]]
);
const app = () =>
["div#app",
currView.deref(),
["footer", "Made with @thi.ng/atom and @thi.ng/hdom"]];
start(document.body, app);
Undo history
The History
type can be used with & behaves like an Atom or Cursor, but
creates snapshots of the current state before applying the new state. By
default history has length of 100 steps, but this is configurable.
db = new atom.History(new atom.Atom({a: 1}))
db.deref()
db.reset({a: 2, b: 3})
db.reset({b: 4})
db.undo()
db.undo()
db.undo()
db.canUndo()
db.redo()
db.redo()
db.redo()
db.canRedo()
Getters & setters
The getter()
and setter()
functions transform a path like a.b.c
into a
function operating directly at the value the path points to in nested object.
For getters, this essentially compiles to val = obj.a.b.c
, with the important
difference that the function returns undefined
if any intermediate values
along the lookup path are undefined (and doesn't throw an error).
The resulting setter function too accepts a single object to operate on and
when called, immutably replaces the value at the given path, i.e. it
produces a selective deep copy of obj up until given path. If any intermediate
key is not present in the given object, it creates a plain empty object for
that missing key and descends further along the path.
s = setter("a.b.c");
s = setter(["a","b","c"]);
s({a: {b: {c: 23}}}, 24)
s({x: 23}, 24)
s(null, 24)
In addition to these higher-order functions, the module also provides
immediate-use wrappers: getIn()
, setIn()
, updateIn()
and deleteIn()
.
These functions are using getter
/ setter
internally, so have same
behaviors.
state = {a: {b: {c: 23}}};
getIn(state, "a.b.c")
setIn(state, "a.b.c", 24)
updateIn(state, "a.b.c", x => x + 1)
deleteIn(state, "a.b.c.")
Only keys in the path will be modified, all other keys present in the given
object retain their original values to provide efficient structural sharing /
re-use. This is the same behavior as in Clojure's immutable maps or those
provided by ImmutableJS (albeit those implementation are completely different -
they're using trees, we're using the ES6 spread op and recursive functional
composition to produce the setter/updater).
s = setter("a.b.c");
a = { x: { y: { z: 1 } }, u: { v: 2 } };
b = s(a, 3);
a.x === b.x
a.x.y === b.x.y
a.u === b.u;
Event bus, interceptors, side effects
Description forthcoming, please check fully commented examples for now:
Authors
License
© 2018 Karsten Schmidt // Apache Software License 2.0