consistate

Consistate: Transactional, Reactive, and Asynchronous State Management and Rendering for JavaScript

Usage no npm install needed!

<script type="module">
  import consistate from 'https://cdn.skypack.dev/consistate';
</script>

README



Consistate: Transactional, Reactive, and Asynchronous State Management and Rendering for JavaScript

About

Open source project of Nezaboodka Software

Demo: https://nezaboodka.gitlab.io/consistate-demo

Inspired by: MobX, Nezaboodka, Excel

Consistate is a JavaScript state management library, which combines the power of reactive, transactional, and asynchronous programming models to simplify and improve productivity of Web UI development. Consistate pushes changes from state (data model) to corresponding UI elements for re-rendering in a seamless, consistent, real-time, and fine-grained way. This is achieved by three basic concepts: state, transaction, and reaction.

State is a set of regular JavaScript objects, which are treated as primary source of data for an application.

Transaction is a unit of work, possibly asynchronous, that makes changes to application state. The changes are made in an isolated data snapshot and become visible only when transaction is successfully completed and committed.

Reaction is a function, which is called on completion of a transaction that changed application state. UI rendering function is a good example of reaction. The result of reaction function is treated as derived value from application state and is cached. Subsequent calls of reaction function return cached result until the application state is changed by any other transaction. When it happens, the cached result is invalidated and the reaction function is executed again to obtain a new result.

Consistate takes full care of tracking dependencies between state (observables) and dependant reactions (observers), and provides fine-grained re-execution of reactions, either immediately or lazily. Execution of dependant reactions is fully consistent and takes place only when all state changes are successfully committed. With Consistate, you no longer need to create data change events in any objects, subscribe to these events in other objects, and manually maintain switching from previous state to new state.

Here is an example in TypeScript, which briefly illustrates the concept and its integration with React.

import { state, transaction, reaction } from "consistate";
import fetch from "node-fetch";

@state // treat all fields of the Model class as state
class Model {
  url: string = "https://gitlab.com/nezaboodka/consistate";
  content: string = "Consistate: Transactional, Reactive, and Asynchronous State Management";
  timestamp: Date = Date.now();

  @transaction // wrap method to run in transactional way
  async load(url: string): Promise<void> {
    // All the changes are made in a separate snapshot, which
    // becomes visible only when transaction is committed. 
    this.url = url;
    this.content = await fetch(url);
    this.timestamp = Date.now();
  } // transaction is completed, dependant reactions are re-executed
}

class View extends ConsistateReactComponent<Model> { // see ConsistateReactComponent below
  @reaction // wrap method to track and react to changes in its dependecies
  render(): ReactNode {
    const m: Model = this.props; // just a shortcut
    return (
      <div>
        <h1>{m.url}</h2>       
        <div>{m.content}</div>
      </div>
    );
    // render is subscribed to m.url and m.content, but not m.timestamp
  }
}

In the example above the result of the method render is cached and reused by React rendering system to avoid redundant generation of the target HTML. When the title and content fields of the data model are changed (by some other code), the existing cached HTML is invalidated and then automatically recomputed by re-executing the render method.

Consistate automatically executes reaction functions upon changes of object properties that were accessed during function execution. To do so, Consistate intercepts property getters and setters of the accessed JavaScript objects. Property getters are used to track what properties (observables) are used by a given reaction function (observer). When some of the properties are changed, the corresponding reaction function is automatically re-executed.

Multiple object properties can be changed in a transactional way - all at once with full respect to the all-or-nothing principle (atomicity, consistency, and isolation). To do so, separate data snapshot is automatically maintained for each transaction. The snapshot is logical and doesn't create full copy of all the data. Intermediate state is visible only inside transaction itself, but is not visible outside of the transaction until it is committed. Compensating actions are not needed in case of transaction failure, because all the changes made by transaction in its logical snapshot are simply discarded. In case transaction is successfully committed, affected reaction functions are invalidated and re-executed in a proper order at the end of the transaction (only when all data changes are committed).

Asynchronous operations (promises) are supported as first class citizens during transaction execution. Transaction may consist of a set of asynchronous operations that are confirmed on completion of all of them. Moreover, any asynchronous operation may spawn other asynchronous operations, which prolong transaction execution until the whole chain of asynchronous operations is fully completed. And in this case, reactions are executed only at the end of entire transaction, thus preventing intermediate inconsistent state being leaked to UI.

Here is an example of integration of Consistate and React:

import { reaction, reactionCacheOf, dismiss } from "consistate";
import * as React from "react";

class ConsistateReactComponent<P> extends React.Component<P> {
  @reaction
  autoUpdate(): void {
    // This method is automatically re-executed when
    // cached value of this.render is invalidated.
    if (Reaction.get(this.render).isInvalidated)
      this.forceUpdate();
  }

  componentDidMount(): void {
    // Mark this.autoUpdate to be re-executed automatically
    // upon invalidation due to changes of its dependencies.
    Reaction.get(this.autoUpdate).latency = 0; // react immediately
    this.autoUpdate(); // first run to identify initial dependencies
  }

  shouldComponentUpdate(nextProps: Readonly<P>): boolean {
    // Update component either if this.render is invalidated
    // or if props are different from the current ones.
    let r = Reaction.get(this.render);
    return r.isInvalidated || r.invalidate(diff(this.props, nextProps));
  }

  componentWillUnmount(): void {
    Reaction.dismissAll(this); // cleanup
  }
}

Differentiators

  • Consistency, clarity, and simplicity are the first priorities
  • Reactions, transactional actions, and asynchronous operations are first-class citizens
  • Undo/redo functionality is built-in and provided out of the box
  • It's minimalistic library, not a framework

API (TypeScript)

// Decorators

export function state(target, prop?): any; // class or field
export function stateless(target, prop): any; // field only
export function transaction(target, prop, pd): any; // method only
export function separateTransaction(target, prop, pd): any; // method only
export function reaction(target, prop, pd): any; // method only

// Transaction

export type F<T> = (...args: any[]) => T;

export class Transaction {
  constructor(hint: string);
  run<T>(func: F<T>, ...args: any[]): T;
  wrap<T>(func: F<T>): F<T>;
  commit(): void;
  seal(): Transaction; // t1.seal().whenFinished().then(fulfill, reject)
  discard(error?: any): Transaction; // t1.seal().whenFinished().then(...)
  finished(): boolean;
  whenFinished(): Promise<void>;
  static run<T>(func: F<T>, ...args: any[]): T;
  static runAs<T>(hint: string, root: boolean, func: F<T>, ...args: any[]): T;
  static get current(): Transaction;
  static debug: number = 0; // 0 = off, 1 = brief, 2 = normal, 3 = noisy, 4 = crazy
}

// Reaction

export abstract class Reaction {
  latency: number;
  monitor: Monitor | undefined;
  readonly cachedResult: any;
  readonly cachedAsyncResult: any;
  readonly cachedError: any;
  readonly invalidator: string | undefined;
  invalidate(invalidator: string | undefined): boolean;
  readonly isInvalidated: boolean;
  static get(method: Function): Reaction;
  static dismissAll(...objects: object[]): Transaction;
}

// Monitor

@state
export class Monitor {
  readonly isIdle: boolean;
  readonly workerCount: number;
  constructor(name: string);
}