A library that helps developing UI (e.g. Angular or React) components in a more functional style where UI logic is representing as a series of immutable state transitions.
A library that helps developing UI (e.g. Angular or React) components in a more functional style where UI logic is representing as a series of immutable state transitions.
Step 1: Install ng-set-state
npm install ng-set-state
Step 2: Create a component with state tracking:
class Component {
constructor() {
initializeImmediateStateTracking(this);
}
arg1: number = 0;
arg2: number = 0;
readonly sum: number = 0;
readonly resultString: string = '';
@With('arg1', 'arg2')
static calcSum(state: ComponentState<Component>): StateDiff<Component> {
return {
sum: state.arg1 + state.arg2
};
}
@With('sum')
static prepareResultString(state: ComponentState<Component>): StateDiff<Component> {
return {
resultString: `Result: ${state.sum}`
};
}
}
const component = new Component();
component.arg1 = 3;
console.log(component.resultString);
//Result: 3
component.arg2 = 2;
console.log(component.resultString);
//Result: 5
All the class properties form a dependency graph:
Once any argument has been changed, all dependencies are recalculated (immediately or through setTimeout)
class Component {
member1: number;
member2: Observable<string>;
member3: Subject<number>;
private member4: number;
method1(){}
}
type State = ComponentState<Component>;
//Equals to
type State = {
readonly member1: number;
readonly member2: string | undefined;
readonly member3: number | undefined;
}
type StateDiff = ComponentStateDiff<Component>;
//Equals to
type StateDiff = {
member1?: number;
member3?: number;
}
In order to make modifiers and actions working within a class this class should be initialized to keep track of its state. It can be done by calling initializeImmediateStateTracking or initializeStateTracking functions e.g.
class Component {
constructor() {
initializeStateTracking(this,{/*options*/});
}
}
where options are:
export type InitStateTrackingOptions<TComponent> = {
immediateEvaluation?: boolean | null,
includeAllPredefinedFields?: boolean | null,
initialState?: ComponentStateDiff<TComponent> | null,
sharedStateTracker?: Object | Object[] | null,
onStateApplied?: ((state: ComponentState<TComponent>, previousState: ComponentState<TComponent>) => void) | null,
errorHandler?: ((e: Error) => boolean) | null
}
immediateEvaluation - indicates if modifiers will be called immediately or trough setTimeout (avoids redundant calls). Default value is false for initializeStateTracking and true for initializeImmediateStateTracking.
includeAllPredefinedFields - by default only properties bound to modifiers are included in component property value snapshots (states). This options allows including all properties that had some explicit values (even null or undefined) at the moment of the initialization. This might be useful to provide some context for modifiers. Default value is false for initializeStateTracking and true for initializeImmediateStateTracking
initialState - initial snapshot of component property values that will be applied at the initialization.
sharedStateTracker - reference to other components with initialized state tracking. changes and actions at that components can be reflected in the initializing component. component can also cause state modification and action firing in the shared components.
onStateApplied - callback function which is called right after a new state has been applied.
errorHandler - callback function which is called when some error has been thrown in modifiers or action handlers
initializeStateTracking and initializeImmediateStateTracking functions returns a reference to so-called stateHandler:
class Component {
stateHandler: IStateHandler<Component>
constructor() {
this.stateHandler = initializeStateTracking(this,{/*options*/});
}
}
this is an object that provides API to manipulate the state tracking:
export interface IStateHandler<TComponent> {
getState(): ComponentState<TComponent>;
modifyStateDiff(diff: StateDiff<TComponent>);
subscribeSharedStateChange(): ISharedStateChangeSubscription | null;
whenAll(): Promise<any>;
execAction<TAction extends StateActionBase>(action: TAction|TAction[]): boolean;
release();
}
@With - decorator that marks a static(!) class method to be called when any of the specified properties have been just changed. The method should return some new values that will be patched to the component properties and a new state will be formed. It also can return new actions to be executed.
//Short syntax
@With('arg1', 'arg2')
static calc(state: ComponentState<Component>): StateDiff<Component> {
return {
sum: state.arg1 + state.arg2
};
}
//Full syntax
@With<Component>('arg1', 'arg2')
.Debounce(10/*ms*/)//This modifier is called when the specified properties are stable for 10ms
.CallOnInit()//Forcibly call right after the initialization
.If(s => s.arg1 > 0)//Call only is the condition is true
.IfNotEqualNull()//Call only all the specified properties are not equal null
static calcSum(
state: ComponentState<Component>,//Current state
previousState: ComponentState<Component>,//Previous state
diff: ComponentStateDiff<Component>//Diff between current and previous
): StateDiff<Component> {
return [{
sum: state.arg1 + state.arg2
}, new Action1(), new Action2()];
}
@WithAsync - decorator that marks a static(!) class method to be called when any of the specified properties have been just changed. The method should return a promise with some new values that will be patched (when the promise is complete) to the component properties and a new state will be formed.
//Short syntax
@WithAsync<Component>('arg1', 'arg2')
static async calcAsync(getState: AsyncContext<Component>): Promise<StateDiff<Component>> {
const initialState = getState();
await initialState.service.doSomethingAsync();
const state = getState();
return {
sum: state.arg1 + state.arg2
};
}
//Full syntax
@WithAsync<Component>('arg1', 'arg2')
.Debounce(10/*ms*/)//Modifier is called when the specified properties are stable for 10ms
.Locks('res1', 'res2')//Modifier will not be called while one the resources are locked by other modifiers
.If(s=>s.arg1 > 2)//Modifier is called is the condition is true
.PreSet(s => ({loading: true}))//Updates state right before calling the modifier
.OnConcurrentLaunchReplace()//Behavior on collision
//or .OnConcurrentLaunchPutAfter()
//or .OnConcurrentLaunchCancel()
//or .OnConcurrentLaunchConcurrent()
//or .OnConcurrentLaunchThrowError()
.OnErrorCall(s => ({isError: true}))//Behavior on error
//or .OnErrorForget()
//or .OnErrorThrow()
.Finally(s => ({loading: true}))//Updates state right after calling the modifier
static async calcAsync(
getState: AsyncContext<Component>,//Functions that returns the current state
previousState: ComponentState<Component>,//Previous state
diff: ComponentStateDiff<Component>//Diff between current and previous
): Promise<StateDiff<Component>> {
const initialState = getState();
await initialState.service.doSomethingAsync();
//Modifier could be canceled due to a collision
if(getState.isCancelled()) {
return null;
}
const state = getState();
return [{
sum: state.arg1 + state.arg2
}, new Action1(), new Action2()];
}
class ActionIncreaseArg1By extends StateActionBase {
constructor(readonly value: number) {
super();
}
}
@WithAction(ActionIncreaseArg1By)
static onAction(
action: ActionIncreaseArg1By,
state: ComponentState<Component>): StateDiff<Component> {
return {
arg1: state.arg1 + action.value
};
}
@WithActionAsync(ActionIncreaseArg1By)
static async onAction(
action: ActionIncreaseArg1By,
getState: AsyncContext<Component>): Promise<StateDiff<Component>> {
await getState().service.doSomething();
const state = getState();
return {
arg1: state.arg1 + action.value
};
}
@WithSharedAsSource(SharedComponent, 'value').CallOnInit()
static onValueChange(arg: WithSharedAsSourceArg<Component, SharedComponent>) :StateDiff<Component> {
return {
message: `Shared value is ${arg.currentSharedState.value}`
};
//or
//return [{
// message: `Shared value is ${arg.currentSharedState.value}`
//}, new Action1(),new Action2(),...];
}
@WithSharedAsTarget(SharedComponent, 'componentValue')
static onCmpValueChange(arg: WithSharedAsTargetArg<Component, SharedComponent>): StateDiff<SharedComponent> {
return {
value: arg.currentState.componentValue * 10
};
//or
//return [{
// value: arg.currentState.componentValue * 10
//}, new Action1(),new Action2(),...];
}
@AsyncInit()
//.Locks('res1', 'res2') - similar to @WithAsync
static async init(getState: AsyncContext<Component>): Promise<StateDiff<Component>> {
const state = getState();
const greetingFormat = await state.services.getGreetingFormat();
return { greetingFormat };
//or
//return [{ greetingFormat }, new Action1(), new Action2()];
}
@BindToShared([SharedClass], [sharedPropName],[index]) - It marks a component field to be a proxy to a shared tracker filed.
@BindToShared(SharedComponent, 'value')
sharedValue: number;
class SharedComponent {
value: number = 0;
constructor() {
initializeImmediateStateTracking(this);
}
}
class Component {
message = '';
componentValue = 0;
@BindToShared(SharedComponent, 'value')
sharedValue: number;
constructor(shared: SharedComponent) {
initializeImmediateStateTracking(this, {
sharedStateTracker: shared
}).subscribeSharedStateChange();
}
onDestroy() {
releaseStateTracking(this);
}
@WithSharedAsSource(SharedComponent, 'value').CallOnInit()
static onValueChange(arg: WithSharedAsSourceArg<Component, SharedComponent>): StateDiff<Component> {
return {
message: `Shared value is ${arg.currentSharedState.value}`
};
}
@WithSharedAsTarget(SharedComponent, 'componentValue')
static onCmpValueChange(arg: WithSharedAsTargetArg<Component, SharedComponent>): StateDiff<SharedComponent> {
return {
value: arg.currentState.componentValue * 10
};
}
}
const sharedComponent = new SharedComponent();
const component = new Component(sharedComponents);
console.log(component.message);
//Shared value is 0
sharedComponent.value = 7;
console.log(component.message);
//Shared value is 7
component.componentValue = 10;
console.log(component.message);
//Shared value is 100
component.sharedValue = 10;
console.log(component.message);
//Shared value is 10
component.onDestroy();
class Component {
name = '';
greetingFormat = '';
greeting = '';
constructor() {
initializeImmediateStateTracking(this);
}
@AsyncInit().Locks('init')
static async init(getState: AsyncContext<Component>): Promise<StateDiff<Component>> {
await delayMs(100);
return {
greetingFormat: 'Hi, %USER%!'
};
}
@WithAsync('name').Locks('init')
static async createGreeting(getState: AsyncContext<Component>): Promise<StateDiff<Component>> {
await delayMs(10);
const state = getState();
return {
greeting: state.greetingFormat.replace('%USER%', state.name)
};
}
}
const component = new Component();
component.name = 'Joe';
await getStateHandler(component).whenAll();
console.log(component.greeting);
//Hi, Joe!
class ActionA extends StateActionBase {
constructor(readonly message: string) {
super();
}
}
class ActionB extends StateActionBase {
constructor(readonly message: string) {
super();
}
}
class ActionS extends StateActionBase {
constructor(readonly message: string) {
super();
}
}
type Logger = {
log: (s: string) => void;
}
class SharedComponent {
stateHandler: IStateHandler<SharedComponent>;
constructor(readonly logger: Logger) {
this.stateHandler =
initializeImmediateStateTracking<SharedComponent>(this);
}
@WithAction(ActionS)
static onActionS(action: ActionS, state: ComponentState<SharedComponent>)
: StateDiff<SharedComponent> {
state.logger.log(`Action S with arg "${action.message}"`);
return [new ActionB(action.message + ' from Shared')];
}
}
class Component {
arg = ''
stateHandler: IStateHandler<Component>;
constructor(readonly logger: Logger,sharedComponent: SharedComponent) {
this.stateHandler
= initializeImmediateStateTracking<Component>(this, {
sharedStateTracker: sharedComponent
});
this.stateHandler.subscribeSharedStateChange();
}
onDestroy() {
this.stateHandler.release();
}
@With('arg')
static onNameChange(state: ComponentState<Component>): StateDiff<Component> {
return [new ActionA(state.arg)];
}
@WithAction(ActionA)
static onActionA(action: ActionA, state: ComponentState<Component>): StateDiff<Component> {
state.logger.log(`Action A with arg "${action.message}"`);
return [new ActionB(action.message), new ActionS(action.message)];
}
@WithAction(ActionB)
static onActionB(action: ActionB, state: ComponentState<Component>): StateDiff<Component> {
state.logger.log(`Action B with arg "${action.message}"`);
return null;
}
}
const logger: Logger = {
log: (m) => console.log(m)
};
const sharedComponent = new SharedComponent(logger);
const component = new Component(logger, sharedComponent);
component.arg = "arg1";
//Action B with arg "arg1"
//Action S with arg "arg1"
//Action B with arg "arg1 from Shared"
component.stateHandler.execAction(new ActionS('arg2'));
//Action S with arg "arg2"
//Action B with arg "arg2 from Shared"
sharedComponent.stateHandler.execAction(new ActionA('arg3'));
//Action A with arg "arg3"
//Action B with arg "arg3"
class Component {
sum: Subject<number>;
constructor(readonly arg1: Observable<number>,readonly arg2: Observable<number>) {
this.sum = new Subject<number>();
initializeImmediateStateTracking<Component>(this);
}
destroy() {
releaseStateTracking(this);
}
@With<Component>('arg1', 'arg2')
static calcSum(s: ComponentState<Component>): StateDiff<Component> {
return {
sum: (s.arg1??0) +(s.arg2??0)
};
}
}
const arg1 = new BehaviorSubject<number>(0);
const arg2 = new BehaviorSubject<number>(0);
const component = new Component(arg1, arg2);
let sum: number = 0;
component.sum.subscribe( s=> sum = s);
arg1.next(2);
console.log(sum.toString());
//2
arg2.next(3);
console.log(sum.toString())
//5;
arg2.next(10);
console.log(sum.toString())
//12;
import React, { ChangeEventHandler } from 'react';
import './App.css';
import { ComponentState, With, IStateHandler, StateDiff, initializeImmediateStateTracking } from 'ng-set-state';
type State = ComponentState<ComponentStateTrack>;
type NewState = StateDiff<ComponentStateTrack>;
class ComponentStateTrack {
arg1Text = '';
arg1Status = 'Empty';
arg2Text = '';
arg2Status = 'Empty';
sumText = 'No proper args'
arg1: number|null = null;
arg2: number|null = null;
stateHandler: IStateHandler<ComponentStateTrack>;
constructor(stateSetter: (s: State) => void) {
this.stateHandler = initializeImmediateStateTracking<ComponentStateTrack>(this,
{
onStateApplied: (s) => stateSetter(s)
});
}
@With('arg1Text')
static parseArg1(state: State): NewState {
if (!state.arg1Text) {
return {
arg1: null,
arg1Status: "Empty"
}
}
const arg1 = parseInt(state.arg1Text);
if(isNaN(arg1)) {
return {
arg1: null,
arg1Status: "Invalid"
};
} else {
return {
arg1,
arg1Status: "Ok"
};
}
}
@With('arg2Text')
static parseArg2(state: State): NewState {
if (!state.arg2Text) {
return {
arg1: null,
arg1Status: "Empty"
}
}
const arg2 = parseInt(state.arg2Text);
if(isNaN(arg2)) {
return {
arg2: null,
arg2Status: "Invalid"
};
} else {
return {
arg2,
arg2Status: "Ok"
};
}
}
@With('arg1', 'arg2')
static setCalcStatus(state: State): NewState {
if (state.arg1 == null || state.arg2 == null) {
return {
sumText: 'No proper args'
};
} else {
return {
sumText: 'Calculating...'
}
}
}
@With('arg1', 'arg2').Debounce(2000/*ms*/)
static setCalcResult(state: State): NewState {
if (state.arg1 != null && state.arg2 != null) {
return {
sumText: (state.arg1 + state.arg2).toString()
};
}
return null;
}
}
export class App extends React.Component<any, State> {
readonly stateTrack: ComponentStateTrack;
constructor(props: any) {
super(props);
this.stateTrack = new ComponentStateTrack(s => this.setState(s));
this.state = this.stateTrack.stateHandler.getState();
}
arg1Change: ChangeEventHandler<HTMLInputElement> = (ev) => {
this.stateTrack.arg1Text = ev.target.value;
};
arg2Change: ChangeEventHandler<HTMLInputElement> = (ev) => {
this.stateTrack.arg2Text = ev.target.value;
};
render = () => {
return (
<div>
<div>
Arg 1: <input onChange={this.arg1Change} /> {this.state.arg1Status}
</div>
<div>
Arg 2: <input onChange={this.arg2Change}/> {this.state.arg2Status}
</div>
<div>
Result: { this.state.sumText }
</div>
</div>
);
}
}