Pushinka: Transactional, Reactive, and Asynchronous State Management
Open source project of Nezaboodka Software
Demo: https://nezaboodka.gitlab.io/pushinka-demo
Inspired by: MobX, Nezaboodka, Excel
Pushinka 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.
Pushinka 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.
Pushinka 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 Pushinka, 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.
`` tsx
import { state, transaction, reaction } from "pushinka";
import fetch from "node-fetch";
@state // treat all fields of the Model class as state
class Model {
url: string = "https://gitlab.com/nezaboodka/pushinka";
content: string = "Pushinka: Transactional, Reactive, and Asynchronous State Management";
timestamp: Date = Date.now();
@transaction // wrap method to run in transactional way
async load(url: string): Promise
// 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 PushinkaReactComponent
@reaction // wrap method to track and react to changes in its dependecies
render(): ReactNode {
const m: Model = this.props; // just a shortcut
return (
{m.url}
{m.content}
);
// 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.
Pushinka automatically executes reaction functions upon changes of object
properties that were accessed during function execution. To do so, Pushinka
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 Pushinka and React:
` tsx
import { reaction, reactionCacheOf, dismiss } from "pushinka";
import * as React from "react";
class PushinkaReactComponent
extends React.Component
{
@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
): 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
}
}
`
* Simplicity, consistency and clarity 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
`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
export class Transaction {
constructor(hint: string);
run
wrap
commit(): void;
seal(): Transaction; // t1.seal().whenFinished().then(fulfill, reject)
discard(error?: any): Transaction; // t1.seal().whenFinished().then(...)
finished(): boolean;
whenFinished(): Promise
static run
static runAs
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);
}
``