@thi.ng/atom
![npm (scoped)](https://img.shields.io/npm/v/@thi.ng/atom.svg)
This project is part of the
@thi.ng/umbrella monorepo.
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
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.
Note: On 2018-03-17 this package was split to remain more focused.
Path based getters/setters have been moved into the new
@thi.ng/paths
package. Likewise, all interceptor based event handling functionality
now lives in the
@thi.ng/interceptors
package.
Installation
yarn add @thi.ng/atom
New since 2018-03-15: You can now create a preconfigured app skeleton
using @thi.ng/atom, @thi.ng/hdom & @thi.ng/router using the
create-hdom-app project
generator:
yarn create hdom-app my-app
cd my-app
yarn install
yarn start
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()
Since v1.1.0 views can also be configured to be eager, instead of the
"lazy" default behavior. If the optional lazy
arg is true (default),
the view's transformer will only be executed with the first deref()
after each value change. If lazy
is false, the transformer function
will be executed immediately after a value change occurred and so can be
used like a selective watch which only triggers if there was an actual
value change (in contrast to normal watches, which execute with each
update, regardless of value change).
Related, the actual value change predicate can be customized. If not
given, the default @thi.ng/api/equiv
will be used.
let x;
let a = new Atom({value: 1})
view = a.addView("value", (y) => (x = y, y * 10), false);
x === 1
x = null
view.deref() === 10
x === null
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 related event
handling
package
and bundled
examples.
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()
Authors
License
© 2018 Karsten Schmidt // Apache Software License 2.0