Tracking component state in Angular using ng-set-state

In the previous article (" Angular Components with Extracted Immutable State ") I showed why changing the fields of components without any restrictions is not always good, and also presented a library that allows you to order changes in the state of components.





Since then, I've changed the concept a bit and made it easier to use. This time I'll focus on a simple (at first glance) example of how it can be used in scripts that would normally require rxJS.





Main idea

, :





, - ( ) , , :





, , , . , 3- , 2- , :





, , . , , Angular :





( stackblitz):





simple-greeting-form.component.ts





@Component({
  selector: 'app-simple-greeting-form',
  templateUrl: './simple-greeting-form.component.html'
})
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
}
      
      



simple-greeting-form.component.html





<div class="form-root">  
  <h1>Greeting Form</h1>
  <label for="ni">Name</label><br />
  <input [(ngModel)]="userName" id="ni" />
  <h1>{{greeting}}</h1>
</div>
      
      



, greeting userName, :





  1. greeting , (change detection);





  2. userName , greeting;





  3. ngModelChange, ;





, - (greeting, «greeting counter») greeting (, greeting = f (userName, template)



), , :





@Component(...)
@StateTracking()
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;

  @With("userName")
  public static greet(state: ComponentState<SimpleGreetingFormComponent>)
    : ComponentStateDiff<SimpleGreetingFormComponent>
  {
    const userName = state.userName === "" 
      ? "'Anonymous'" 
      : state.userName;

    return {
      greeting: `Hello, ${userName}!`
    }
  }
}
      
      



@StateTracking initializeStateTracking ( Angular):





@Component(...)
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
  
  constructor(){
    initializeStateTracking(this);
  }
}
      
      



@StateTracking ( initializeStateTracking) , , , .





:





  ...
  @With("userName")
  public static greet(state: ComponentState<SimpleGreetingFormComponent>)
    : ComponentStateDiff<SimpleGreetingFormComponent>
  {
      ...
  }
  ...
      
      



, , , . , .





, .





, «» :





@With("userName")
public static greet(
  state: ComponentState<SimpleGreetingFormComponent>,
  previous: ComponentState<SimpleGreetingFormComponent>,
  diff: ComponentStateDiff<SimpleGreetingFormComponent>
)
: ComponentStateDiff<SimpleGreetingFormComponent>
{
  ...
}
      
      



ComponentState ComponentStateDiff — (Typescript mapped types), (event emitters). ComponentState “ ” ( (immutable)), ComponentStateDiff , .





:





type State = ComponentState<SimpleGreetingFormComponent>;
type NewState = ComponentStateDiff<SimpleGreetingFormComponent>;
...
  @With("userName")
  public static greet(state: State): NewState
  {
    ...
  }
      
      



@With , (!) . Typescript , ( «» (pure)).





. , :





@Component(...)
@StateTracking<SimpleGreetingFormComponent>({
  onStateApplied: (c,s,p)=> c.onStateApplied(s,p)
})
export class SimpleGreetingFormComponent {
  userName: string;

  greeting:  string;

  private onStateApplied(current: State, previous: State){
    console.log("Transition:")
    console.log(`${JSON.stringify(previous)} =>`)
    console.log(`${JSON.stringify(current)}`)
  }

  @With("userName")
  public static greet(state: State): NewState
  {
      ...
  }  
}
      
      



onStateApplied — “-” (hook), , - , :





Transition:
{} =>
{"userName":"B","greeting":"Hello, B!"}

Transition:
{"userName":"B","greeting":"Hello, B!"} =>
{"userName":"Bo","greeting":"Hello, Bo!"}

Transition:
{"userName":"Bo","greeting":"Hello, Bo!"} =>
{"userName":"Bob","greeting":"Hello, Bob!"}
      
      



, , , . , , Debounce @With:





@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState
{
    ...
}
...
      
      



3 :





Transition:
{} =>
{"userName":"B"}

Transition:
{"userName":"B"} =>
{"userName":"Bo"}

Transition:
{"userName":"Bo"} =>
{"userName":"Bob"}

Transition:
{"userName":"Bob"} =>
{"userName":"Bob","greeting":"Hello, Bob!"}
      
      



, :





...
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
  isThinking:  boolean = false;

  ...

  @With("userName")
  public static onNameChanged(state: State): NewState{
    return{
      isThinking: true
    }
  }

  @With("userName").Debounce(3000/*ms*/)
  public static greet(state: State): NewState
  {
    const userName = state.userName === "" 
      ? "'Anonymous'" 
      : state.userName;

    return {
      greeting: `Hello, ${userName}!`,
      isThinking: false
    }
  }
}
      
      



...
<h1 *ngIf="!isThinking">{{greeting}}</h1>
<h1 *ngIf="isThinking">Thinking...</h1>
...
      
      



, , - , 3 , greeting , , “Thinking…” , . , @Emitter() userName:





@Emitter()
userName: string;
      
      



, , , .





- "", userName null, :





...
@With("userName")
public static onNameChanged(state: State): NewState{
  if(state.userName == null){
    return null;
  }

  return{
    isThinking: true
  }
}

@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState
{
  if(state.userName == null){
    return null;
  }
  
  const userName = state.userName === "" 
    ? "'Anonymous'" 
    : state.userName;

  return {
    greeting: `Hello, ${userName}!`,
    isThinking: false,
    userName: null
  }
}
...
      
      



, . , [Enter] ((keydown.enter) = "onEnter ()"



), :





...
userName: string | null;
immediateUserName: string | null;

onEnter(){
  this.immediateUserName = this.userName;
}
...
@With("userName")
public static onNameChanged(state: State): NewState{
  ...
}

@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState {
  ...
}

@With("immediateUserName")
public static onImmediateUserName(state: State): NewState{
  if(state.immediateUserName == null){
    return null;
  }

  const userName = state.immediateUserName === "" 
    ? "'Anonymous'" 
    : state.immediateUserName;

  return {
    greeting: `Hello, ${userName}!!!`,
    isThinking: false,
    userName: null,
    immediateUserName: null
  }
}
...
      
      



, , [Enter] - - :





<h1 *ngIf="isThinking">Thinking ({{countdown}} sec)...</h1>
      
      



...
countdown: number = 0;
...
@With("userName")
public static onNameChanged(state: State): NewState{
  if(state.userName == null){
    return null;
  }

  return{
    isThinking: true,
    countdown: 3
  }
}
...
@With("countdown").Debounce(1000/*ms*/)
public static countdownTick(state: State): NewState{
  if(state.countdown <= 0) {
    return null
  }

  return {countdown: state.countdown-1};
}
      
      



:





, . , [Enter], 3 - , . , isThinking:





...
@With("isThinking")
static reset(state: State): NewState{
  if(!state.isThinking){
    return{
      userName: null,
      immediateUserName: null,
      countdown: 0
    };
  }
  return null;
}
...
      
      



(Change Detection)

, , Angular, - Default. , - OnPush, , .





, , , , , , - :





...
constructor(readonly changeDetector: ChangeDetectorRef){
}
...
private onStateApplied(current: State, previous: State){
  this.changeDetector.detectChanges();
  ...
      
      



OnPush (Change Detection Strategy).





(Output Properties)

(Event emitters) , . Change :





greeting:  string;

@Output()
greetingChange = new EventEmitter<string>();
      
      



, (, *ngIf), , , . , . , !





:





greeting-service.ts





@StateTracking({includeAllPredefinedFields:true})
export class GreetingService implements IGreetingServiceForm {
  userName: string | null = null;
  immediateUserName: string | null = null;
  greeting:  string = null;
  isThinking:  boolean = false;
  countdown: number = 0;

  @With("userName")
  static onNameChanged(state: State): NewState{
    ...
  }
  @With("userName").Debounce(3000/*ms*/)
  static greet(state: State): NewState
  {
    ...
  }
  @With("immediateUserName")
  static onImmediateUserName(state: State): NewState{
    ...
  }
  @With("countdown").Debounce(1000/*ms*/)
  static countdownTick(state: State): NewState{
    ...
  }
  @With("isThinking")
  static reset(state: State): NewState{
    ...
  }
}
      
      



.





includeAllPredefinedFields , ( null) .





, :





  1. dependency injection;





  2. ;





  3. , ;





  4. - , - OnPush.





:





@Component({...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ComplexGreetingFormComponent 
  implements OnDestroy, IGreetingServiceForm {

  private _subscription: ISharedStateChangeSubscription;

  @BindToShared()
  userName: string | null;

  @BindToShared()
  immediateUserName: string | null;

  @BindToShared()
  greeting:  string;

  @BindToShared()
  isThinking:  boolean = false;

  @BindToShared()
  countdown: number = 0;

  constructor(greetingService: GreetingService, cd: ChangeDetectorRef) {
    const handler = initializeStateTracking<ComplexGreetingFormComponent>(this,{
      sharedStateTracker: greetingService,
      onStateApplied: ()=>cd.detectChanges()
    });
    this._subscription = handler.subscribeSharedStateChange();
  }

  ngOnDestroy(){
    this._subscription.unsubscribe();
  }

  public onEnter(){
    this.immediateUserName = this.userName;
  }
}
      
      



initializeStateTracking ( @StateTracking(), ), .





(_subscription: ISharedStateChangeSubscription



) onStateApplied , () . Default , .





, . handler.release() releaseStateTracking(this), , , .





, .





, :





export type LogItem = {
  id: number | null
  greeting: string,
  status: LogItemState,
}

@Injectable()
export class GreetingLogService implements IGreetingServiceLog, IGreetingServiceOutput {

  @BindToShared()
  greeting:  string;

  log: LogItem[] = [];

  logVersion: number = 0;

  identity: number = 0;

  pendingCount: number = 0;

  savingCount: number = 0;

  ...

  constructor(greetingService: GreetingService){
    const handler = initializeStateTracking(this,{
      sharedStateTracker: greetingService, 
      includeAllPredefinedFields: true});
      
    handler.subscribeSharedStateChange();    
  }

  ...
}
      
      



greeting, log. logVersion , , :





...
@With("greeting")
static onNewGreeting(state: State): NewState{
    state.log.push({id: null, greeting: state.greeting, status: "pending"});

    return {logVersion: state.logVersion+1};
}
...
      
      



" ", , :





@With("logVersion")
static checkStatus(state: State): NewState{

  let pendingCount = state.pendingCount;

  for(const item of state.log){
    if(item.status === "pending"){
      pendingCount++;
    }
    else if(item.status === "saving"){
      savingCount++;
    }
  }

  return {pendingCount, savingCount};
}

@With("pendingCount").Debounce(2000/*ms*/)
static initSave(state: State): NewState{

  if(state.pendingCount< 1){
    return null;
  }

  for(const item of state.log){
    if(item.status === "pending"){
      item.status = "saving";
    }
  }

  return {logVersion: state.logVersion+1};
}
      
      



, , “ ”:





...
  @WithAsync("savingCount").OnConcurrentLaunchPutAfter()
  static async save(getState: ()=>State): Promise<NewState>{
      const initialState = getState();

      if(initialState.savingCount < 1){
        return null;
      }

      const savingBatch = initialState.log.filter(i=>i.status === "saving");

      await delayMs(2000);//Simulates sending data to server 

      const stateAfterSave = getState();

      let identity = stateAfterSave.identity;

      savingBatch.forEach(l=>{
        l.status = "saved",
        l.id = ++identity
      });

      return {
        logVersion: stateAfterSave.logVersion+1,
        identity: identity
      };      
  }
...
      
      



, :





  1. WithAsync With;





  2. ( OnConcurrentLaunchPutAfter);





  3. , .





In the same way, we can implement deleting and restoring greetings, but I will skip this part, since there is nothing new in it. As a result, our form will look like this:






We just looked at a sample user interface with relatively complex asynchronous behavior. However, it turns out that implementing this behavior is not that difficult using the concept of a series of immutable states. At least it can be considered as an alternative to RxJs.






  1. Stackblitz article code





  2. Link to previous article: Angular Components with Extracted Immutable State





  3. Link not source code ng-set-state








All Articles