Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@thi.ng/atom

Package Overview
Dependencies
Maintainers
1
Versions
241
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@thi.ng/atom

Mutable wrapper for a immutable values

  • 0.9.2
  • Source
  • npm
  • Socket score

Version published
Weekly downloads
1.1K
increased by123.71%
Maintainers
1
Weekly downloads
 
Created
Source

@thi.ng/atom

npm (scoped)

About

Clojure inspired mutable wrappers for (usually) immutable values, with support for watches, cursors (direct access to nested values), derived view subscriptions and 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.

Installation

yarn add @thi.ng/atom

Usage examples

A complete minimal webapp example is in the /examples/todo-list directory.

Live demo here

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

// 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

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()

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 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...

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 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()
// {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

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");
// or
s = setter(["a","b","c"]);

s({a: {b: {c: 23}}}, 24)
// {a: {b: {c: 24}}}

s({x: 23}, 24)
// { x: 23, a: { b: { c: 24 } } }

s(null, 24)
// { a: { b: { c: 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")
// 23

setIn(state, "a.b.c", 24)
// {a: {b: {c: 24}}}

// apply given function to path value
updateIn(state, "a.b.c", x => x + 1)
// {a: {b: {c: 24}}}

// immutably remove path key
deleteIn(state, "a.b.c.")
// {a: {b: {}}}

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

// original
a = { x: { y: { z: 1 } }, u: { v: 2 } };
// updated version
b = s(a, 3);
// { x: { y: { z: 1 } }, u: { v: 2 }, a: { b: { c: 3 } } }

// verify anything under keys `x` & `u` is still identical
a.x === b.x // true
a.x.y === b.x.y // true
a.u === b.u; // true

Authors

  • Karsten Schmidt

License

© 2018 Karsten Schmidt // Apache Software License 2.0

Keywords

FAQs

Package last updated on 08 Mar 2018

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc