Skip to content

Latest commit

 

History

History

atom

@thi.ng/atom

npm version npm downloads Twitter Follow

This project is part of the @thi.ng/umbrella monorepo.

About

Mutable wrappers for nested immutable values with optional undo/redo history and transaction support.

Clojure inspired wrappers with infrastructure support for:

  • watches
  • derived view subscriptions
  • cursors (direct R/W access to nested values)
  • transacted updates
  • 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 centralized atom around a nested, immutable object as single source of truth within an application.

Status

STABLE - used in production

Installation

yarn add @thi.ng/atom

Package sizes (gzipped): ESM: 2.0KB / CJS: 2.0KB / UMD: 2.0KB

Dependencies

Usage examples

Several demos in this repo's /examples directory are using this package.

A selection:

devcards

Live demo | Source

hdom-dropdown-fuzzy

Live demo | Source

rotating-voronoi

screenshot

Live demo | Source

router-basics

Basic @thi.ng/router & @thi.ng/hdom app skeleton

Live demo | Source

rstream-dataflow

Live demo | Source

rstream-grid

screenshot

Interactive grid generator, SVG generation & export, undo/redo support

Live demo | Source

rstream-spreadsheet

Live demo | Source

svg-waveform

screenshot

Additive waveform synthesis & SVG visualization with undo/redo

Live demo | Source

todo-list

Live demo | Source

triple-query

Live demo | Source

API

Generated API docs

Atom

An Atom is a mutable wrapper for supposedly 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);

// obtain value via deref()
a.deref();
// 23

// add watch to observe value changes
a.addWatch("foo", (id, prev, curr) => console.log(`${id}: ${prev} -> ${curr}`));
// true

// example update function
const add = (x, y) => x + y;

// apply given function to current value
// (incl. any additional arguments passed to swap)
// this is the same as:
// a.reset(adder(a.deref(), 1))
a.swap(add, 1);
// foo: 23 -> 24

// reset atom's value
a.reset(42);
// foo: 24 -> 42

When atoms are used to wrap nested object values, the resetIn() / swapIn() methods can be used to directly update nested values. These updates are handled via immutable setters provided by @thi.ng/paths.

const db = new Atom<any>({ a: { b: 1, c: 2 } });

db.resetIn("a.b", 100);
// { a: { b: 100, c: 2 } }

db.swapIn("a.c", (x) => x + 1);
// { a: { b: 100, c: 3 } }

// alternatively, the lookup path can be given as array
// see @thi.ng/paths for further reference
db.swapIn(["a", "c"], (x) => x + 1);
// { a: { b: 100, c: 4 } }

Transacted updates

Since v3.1.0, multiple sequential state updates can be grouped in transactions and then applied in one go (or canceled altogether). This can be useful to produce a clean(er) sequence of undo snapshots (see further below) and avoids multiple / obsolete invocations of watches caused by each interim state update. Using a transaction, the parent state is only updated once and watches too are only notified once after each commit.

Transactions can also be canceled, thus not impacting the parent state at all. Nested transactions are not supported and attempting to do so will throw an error.

The Transacted class can wrap any existing IAtom implementation, e.g. Atom, Cursor or History instances and implements IAtom itself...

const db = new Atom({ a: 1, b: 2 });
const tx = new Transacted(db);

// start transaction
tx.begin();

// perform multiple updates
// (none of them are applied until `commit` is called)
// IMPORTANT: calling any of these update methods without
// a running transaction will throw an error!
tx.resetIn("a", 11);
tx.resetIn("c", 33);

// tx.deref() will always return latest state
tx.deref()
// { a: 11, b: 2, c: 33 }

// however, at this point db.deref() still yields pre-transaction state
db.deref()
// { a: 1, b: 2 }

// apply all changes at once (or `cancel()` transaction)
tx.commit();
// { a: 11, b: 2, c: 33 }

// verify parent state
db.deref()
// { a: 11, b: 2, c: 33 }

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}}})
// cursor to `b` value
b=new atom.Cursor(a, "a.b")
// cursor to `c` value, relative to `b`
c=new atom.Cursor(b, "c")

c.reset(2);

b.deref();
// { c: 2 }

a.deref();
// { a: { b: { c: 2 } } }

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 state
main = new atom.Atom({ a: { b: { c: 23 }, d: { e: 42 } }, f: 66 });

// cursor to `c` value
cursor = new atom.Cursor(main, "a.b.c");
// or
cursor = new atom.Cursor(main, ["a","b","c"]);

// alternatively provide path implicitly via lookup & update functions
// both fns will be called with cursor's parent state
// this allows the cursor implementation to work with any data structure
// as long as the updater DOES NOT mutate in place
cursor = new atom.Cursor(
    main,
    (s) => s.a.b.c,
    (s, x) => ({...s, a: {...s.a, b: {...s.a.b, c: x}}})
);

// add watch just as with Atom
cursor.addWatch("foo", console.log);

cursor.deref()
// 23

cursor.swap(x => x + 1);
// foo 23 24

main.deref()
// { a: { b: { c: 24 }, d: { e: 42 } }, f: 66 }

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}});

// create a view for a's value
viewA = db.addView("a");

// create a view for c's value w/ transformer
viewC = db.addView("b.c", (x) => x * 10);

viewA.deref()
// 1

viewC.deref()
// 20

// update the atom
db.swap((state) => atom.setIn(state, "b.c", 3))

// views can indicate if their value has changed
// (will be reset to `false` after each deref)

// here viewA hasn't changed (we only updated `c`)
viewA.changed()
// false
viewC.changed()
// true

// the transformer function is only executed once per value change
viewC.deref()
// 30

// just returns current cached transformed value
viewC.deref()
// 30

// discard views
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/equiv will be used.

let x;
let a = new Atom({value: 1})

// create an eager view by passing `false` as last arg
view = a.addView("value", (y) => (x = y, y * 10), false);

// check `x` to verify that transformer already has run
x === 1
// true

// reset x
x = null

// verify transformed value
view.deref() === 10
// true

// verify transformer hasn't rerun because of deref()
x === null
// true

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";

// central immutable app state
const db = new Atom({ state: "login" });

// define views for different state values
const appState = db.addView<string>("state");
const error = db.addView<string>("error");
// specify a view transformer for the username value
const user = db.addView<string>(
    "user.name",
    (x) => x ? x.charAt(0).toUpperCase() + x.substr(1) : null
);

// state update functions
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");
};

// components for different app states
// note how the value views are used here
const uiViews = {
    // dummy login form
    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"]
        ]
};

// finally define another derived view for the app state value
// including a transformer, which maps the current app state value
// to its correct UI component (incl. a fallback for illegal app states)
const currView = db.addView(
    appState.path,
    (state) =>
        uiViews[state] ||
        ["div", ["h1", `No component for state: ${state}`]]
);

// app root component
const app = () =>
    ["div#app",
        currView.deref(),
        ["footer", "Made with @thi.ng/atom and @thi.ng/hdom"]];

start(document.body, app);

Undo / Redo 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, the history has length of 100 steps, though this is configurable via ctor args.

// create history w/ max. 100 steps
db = new atom.History(new atom.Atom({a: 1}), 100)
db.deref()
// {a: 1}

db.reset({a: 2, b: 3})
db.reset({b: 4})

db.undo()
// {a: 2, b: 3}

db.undo()
// {a: 1}

db.undo()
// undefined (no more undo possible)
db.canUndo()
// false

db.redo()
// {a: 2, b: 3}

db.redo()
// {b: 4}

db.redo()
// undefined (no more redo possible)

db.canRedo()
// false

Authors

Karsten Schmidt

License

© 2016 - 2020 Karsten Schmidt // Apache Software License 2.0