Skip to content

Bang Usages

Chau Tran edited this page Jan 20, 2022 · 10 revisions

ngx-bang has two different workflows for two different state management strategies:

  • Component
  • Component Provider

Component

Before we get into ngx-bang, let's take a look at the following component:

@Component({
    template: `
        <button (click)="onDecrement()">-</button>
        <p>{{count}}</p>
        <button (click)="onIncrement()">+</button>
        <p>You have clicked increment: {{incrementCount}}</p>
        <p>You have clicked decrement: {{decrementCount}}</p>
    `, 
    changeDetection: ChangeDetectionStategy.OnPush
})
export class CounterComponet {
    count = 0;
    incrementCount = 0;
    decrementCount = 0;
    
    onIncrement() {
        this.count += 1;
        this.incrementCount += 1;
    }
    
    onDecrement() {
        this.count -= 1;
        this.decrementCount += 1;
    }
}

This simple CounterComponent works as plain TypeScript using Class's properties. Now let's add a requirement: "Logging to Console how many seconds have passed since the last time count changes?". Managing states with Class properties becomes tricky when adding some "Observability" requirements. If you're familiar with Angular, you might do something like the following:

@Component({
   template: `
    <button (click)="onDecrement()">-</button>
    <!--   ๐Ÿ‘‡ use the Observable with async pipe -->
    <p>{{count$ | async}}</p>
    <button (click)="onIncrement()">+</button>
    <p>You have clicked increment: {{incrementCount}}</p>
    <p>You have clicked decrement: {{decrementCount}}</p>
   `,
    changeDetection: ChangeDetectionStategy.OnPush
})
export class CounterComponet {
    // ๐Ÿ‘‡ no longer a primitive value
    // count = 0;
    //                 ๐Ÿ‘‡ count is now a BehaviorSubject (from rxjs) that you can observe for changes
    private readonly $count = new BehaviorSubject(0);
    //         ๐Ÿ‘‡ create a readonly-Observable from BehaviorSubject
    readonly count$ = this.$count.asObservable();
    //         ๐Ÿ‘‡ declare a Subscription to hold "$count" value listener
    private countSubscription?: Subscription;

    incrementCount = 0;
    decrementCount = 0;

    ngOnInit() {
        this.countSubscription = this.count$.pipe(
            // ๐Ÿ‘‡ introduce "hard" RxJs operator (read more about switchMap on RxJs https://rxjs.dev)
            switchMap(() => interval(1000).pipe(map(tick => tick + 1)))
        ).subscribe((seconds) => {
            console.log(`It has been ${seconds}s since the last time you changed "count"`);
        });
    }

    ngOnDestroy() {
        //     ๐Ÿ‘‡ clean up subscription on destroy
        this.countSubscription?.unsubscribe();
    }
    
    onIncrement() {
        // this.count += 1;
        //           ๐Ÿ‘‡ updating the BehaviorSubject value
        this.$count.next(this.$count.getValue() + 1);
        this.incrementCount += 1;
    }
    
    onDecrement() {
        // this.count -= 1;
        //           ๐Ÿ‘‡ updating the BehaviorSubject value
        this.$count.next(this.$count.getValue() - 1);
        this.decrementCount += 1;
    }
}

You can see that the code has become a lot more complex with just one small requirement, and the crux of this requirement is all about "listen for count changes." RxJS is a powerful library, and sometimes it is needed for complex asynchronous flows, but "Great power comes great responsibilities."

Let's see how ngx-bang can help.

Create the "state"

First, ngx-bang introduces a state() method to hold your states.

import { state } from 'ngx-bang';

@Component({/*...*/})
export class CounterComponent {
    // private readonly $count = new BehaviorSubject(0);
    // readonly count$ = this.$count.asObservable();
    // private countSubscription?: Subscription;
    //
    // incrementCount = 0;
    // decrementCount = 0;
    
    //       ๐Ÿ‘‡ call state with the initial states to get a StateProxy
    state = state({count: 0, incrementCount: 0, decrementCount: 0});
    
    /*...*/
}

state<TState>() returns a StateProxy<TState> that you can do many things with (we will explore all of them in later sections).

Read the "state"

Second, ngx-bang exports a snapshot() method to READ the StateProxy. The rule of thumb when using ngx-bang is to "Read from the snapshot, Write to state" (From valtio: "_ Rule of thumb: read from snapshots, mutate the source_")

import { state, snapshot } from 'ngx-bang';

@Component({/*...*/})
export class CounterComponent {
    state = state({count: 0, incrementCount: 0, decrementCount: 0});
    
    /*...*/
    
    onIncrement() {
        const { count, incrementCount } = snapshot(this.state);
        //       ๐Ÿ‘‡ write to state
        //       ๐Ÿ‘‡          ๐Ÿ‘‡ read from snapshot 
        this.state.count = count + 1;
        this.state.incrementCount = incrementCount + 1;
    }
    
    onDecrement() {
        const { count, decrementCount } = snapshot(this.state);
        this.state.count = count - 1;
        this.state.decrementCount = decrementCount + 1;
    }
}

StatefulDirective and Change Detection

Next, let's update our template.

import { state, snapshot } from 'ngx-bang';

@Component({
    template: `
        <!-- wraps your template with *stateful. read from the exposed snapshot -->
        <ng-container *stateful="state; let snapshot">
            <button (click)="onDecrement()">-</button>
            <p>{{snapshot.count}}</p>
            <button (click)="onIncrement()">+</button>
            <p>You have clicked increment: {{snapshot.incrementCount}}</p>
            <p>You have clicked decrement: {{snapshot.decrementCount}}</p>
        </ng-container>
    `
})
export class CounterComponent {
    state = state({count: 0, incrementCount: 0, decrementCount: 0});
    
    /*...*/
    
    onIncrement() {
        /*...*/
    }
    
    onDecrement() {
        /*...*/
    }
}

Component Template, where the state is read, should be wrapped with StatefulDirective (*stateful) with the StateProxy. The directive augments the StateProxy to be aware of Change Detection and when the Component is destroyed. *stateful exposes a template variable snapshot to read the state easily on the template.

However, it seems like we just introduce more code with ngx-bang if we stop here. Let's not forget about the "Observability" requirement, and here's how ngx-bang solves this problem.

Observability

ngx-bang exports a method effect() that handles side-effects on state change. If you're familiar with React, this is similar to how useEffect() works.

import { state, snapshot, effect } from 'ngx-bang';

@Component({
    template: `
        <ng-container *stateful="state; let snapshot">
            <!-- the template -->
        </ng-container>
    `
})
export class CounterComponent {
    state = state({count: 0, incrementCount: 0, decrementCount: 0});
    
    ngOnInit() {
        //          ๐Ÿ‘‡ the StateProxy
        //          ๐Ÿ‘‡         ๐Ÿ‘‡ what property/properties you want to watch
        //          ๐Ÿ‘‡         ๐Ÿ‘‡      ๐Ÿ‘‡ the effect function
        effect(this.state, ['count'], () => {
            const sub = interval(1000)
                .pipe(map(tick => tick + 1))
                .subscribe((seconds) => {
                    console.log(`It has been ${seconds}s since the last time you changed "count"`);
                });
            
            //     ๐Ÿ‘‡ the clean up function
            return () => {
                sub.unsubscribe();
            }
        });
    }
    
    onIncrement() {
        /*...*/
    }
    
    onDecrement() {
        /*...*/
    }
}

And that's it, no OnDestroy, no BehaviorSubject needed. Passing the StateProxy to effect() allows the StateProxy to keep track of this "listener" and will clean up accordingly when the Component's destroyed. Here's the complete code of CounterComponent

interface CounterState {
    count: number;
    incrementCount: number;
    decrementCount: number;
}

@Component({
    template: `
        <ng-container *stateful="state; let snapshot">
            <button (click)="onDecrement()">-</button>
            <p>{{snapshot.count}}</p>
            <button (click)="onIncrement()">+</button>
            <p>You have clicked increment: {{snapshot.incrementCount}}</p>
            <p>You have clicked decrement: {{snapshot.decrementCount}}</p>
        </ng-container>
    `
})
export class CounterComponent implements OnInit {
    state = state<CounterState>({count: 0, incrementCount: 0, decrementCount: 0});
    
    ngOnInit() {
        effect(this.state, ['count'], () => {
            const sub = interval(1000)
                .pipe(map(tick => tick + 1))
                .subscribe((tick) => {
                    console.log(`It has been ${tick}s since the last time you changed "count"`);
                });
            return () => {
                sub.unsubscribe();
            }
        });
    }
    
    onIncrement() {
        const { count, incrementCount } = snapshot(this.state);
        this.state.count = count + 1;
        this.state.incrementCount = incrementCount + 1;
    }
    
    onDecrement() {
        const { count, decrementCount } = snapshot(this.state);
        this.state.count = count - 1;
        this.state.decrementCount = decrementCount + 1;
    }
}

Counter Demo on Stackblitz

Component Provider

Sometimes, you would like to manage the states inside a Service instead of keeping the states in the Component. Here's how you can do it with ngx-bang

interface CounterState {
    count: number;
    incrementCount: number;
    decrementCount: number;
}

@Injectable()
export class CounterStore {
    state = state<CounterState>({count: 0, incrementCount: 0, decrementCount: 0});

    setupInterval() {
        effect(this.state, ['count'], () => {
            const sub = interval(1000)
                .pipe(map(tick => tick + 1))
                .subscribe((tick) => {
                    console.log(`It has been ${tick}s since the last time you changed "count"`);
                });
            return () => {
                sub.unsubscribe();
            }
        });
    }

    increment() {
        const {count, incrementCount} = snapshot(this.state);
        this.state.count = count + 1;
        this.state.incrementCount = incrementCount + 1;
    }

    decrement() {
        const {count, decrementCount} = snapshot(this.state);
        this.state.count = count - 1;
        this.state.decrementCount = decrementCount + 1;
    }
}

Now your CounterComponent will look like:

@Component({
    template: `
        <!--                          ๐Ÿ‘‡ make this aware of CD --> 
        <ng-container *stateful="store.state; let snapshot">
            <button (click)="store.decrement()">-</button>
            <p>{{snapshot.count}}</p>
            <button (click)="store.increment()">+</button>
            <p>You have clicked increment: {{snapshot.incrementCount}}</p>
            <p>You have clicked decrement: {{snapshot.decrementCount}}</p>
        </ng-container>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [CounterStore]
})
export class CounterComponent implements OnInit {

    constructor(public store: CounterStore) {
    }

    ngOnInit() {
        this.store.setupInterval();
    }
}

Zone.js

A side-note about Zone.js and ChangeDetectionStrategy.OnPush. Normally, when you keep states in your Component, changing the states inside a callback outside of Zone.js (like setTimeout or timer()), the template will not get updated because Zone.js doesn't trigger change detection automatically.

ngx-bang solves this problem out of the box.

interface CounterState {
    count: number;
    incrementCount: number;
    decrementCount: number;
}

@Component({
    template: `
        <ng-container *stateful="state; let snapshot">
            <button (click)="onDecrement()">-</button>
            <p>{{snapshot.count}}</p>
            <button (click)="onIncrement()">+</button>
            <p>You have clicked increment: {{snapshot.incrementCount}}</p>
            <p>You have clicked decrement: {{snapshot.decrementCount}}</p>
        </ng-container>
    `
})
export class CounterComponent implements OnInit {
    state = state<CounterState>({count: 0, incrementCount: 0, decrementCount: 0});
    
    ngOnInit() {
        /* ... */

        /**
         * You will see "count" on the template gets updated accordingly
         * at 5s and 10s marks
         */

        timer(5000).subscribe(() => {
            this.state.count = 10;
        });
        
        setTimeout(() => {
            this.state.count = 20;
        }, 10000)
    }
    
    onIncrement() {
        /* ... */
    }
    
    onDecrement() {
        /* ... */
    }
}

watch (debugging)

ngx-bang exports a method watch(). As the name implies, you can use watch() to watch for state changes.

watch() is similar to effect() but it does not have clean up logic built-in.

import { watch } from 'ngx-bang';

export class CounterComponent {
    state = state<CounterState>({count: 0, incrementCount: 0, decrementCount: 0});
    
    ngOnInit() {
        watch(this.state, (operations) => {
            //          ๐Ÿ‘‡ an array of all changes happened to "state"
            //          ๐Ÿ‘‡ eg: clicking + will issue two operations: 'set' on "count" and 'set' on "incrementCount"
            console.log(operations);
        })
    }
}