Skip to content

Bang Async

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

ngx-bang/async provides better DX with asynchronous tasks like RxJS and Promises.

To support the argument of keeping ngx-bang simple, ngx-bang/async was created to be dependent on RxJS to provide some DX improvements to users. The fact that ngx-bang/async is a secondary-entry point means that it is optional.

asyncConnect

Let's get back to our CounterComponent and expand our state.

If you're not familiar with CounterComponent, please check out ngx-bang Usages

Assuming we have a new requirement that is to move the "Seconds have passed since last count changed" to a property in our state

// the value of interval() needs to become our "state" value now
// instead of logging to the console
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();
    }
});

Let's start by adjusting our CounterComponent state to include secondsPassed

interface CounterState {
    count: number;
    incrementCount: number;
    decrementCount: number;
    secondsPassed: 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>
            <p>Seconds since last "count" changed": {{snapshot.secondsPassed}}s</p>
        </ng-container>
    `
})
export class CounterComponent implements OnInit {
    state = state<CounterState>({
        count: 0, 
        incrementCount: 0, 
        decrementCount: 0, 
        secondsPassed: 0
    });
    
    ngOnInit() {
        // remove the effect. We'll replace it with something else
    }
    
    onIncrement() {
        /* ... */
    }
    
    onDecrement() {
        /* ... */
    }
}

Now instead of effect, we'll use asyncConnect which is imported from ngx-bang/async

import { asyncConnect } from 'ngx-bang/async';

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

@Component({
    template: `
        <!-- the template -->
    `
})
export class CounterComponent implements OnInit {
    state = state<CounterState>({
        count: 0, 
        incrementCount: 0, 
        decrementCount: 0, 
        secondsPassed: 0
    });
    
    ngOnInit() {
        asyncConnect(
            // πŸ‘‡ connect to the Proxy
            this.state, 
            // πŸ‘‡ for this key 
            'secondsPassed', 
            // πŸ‘‡ update with value from this stream
            interval(1000).pipe(map(tick => tick + 1))
        );
    }
    
    onIncrement() {
        /* ... */
    }
    
    onDecrement() {
        /* ... */
    }
}

asyncConnect() subscribes to the connector (eg: interval()) and will automatically clean up on Component's destroy as well. Everytime the connector emits new value, state.secondsPassed gets updated with that value.

If you save and go to the template, you'll see that secondsPassed increments every second now.

With deps

However, we miss one part of the requirement: "when count changes". Right now, secondsPassed just keeps incrementing every second and will not reset when we change count. Let's fix that

import { asyncConnect } from 'ngx-bang/async';

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

@Component({
    template: `
        <!-- the template -->
    `
})
export class CounterComponent implements OnInit {
    state = state<CounterState>({
        count: 0, 
        incrementCount: 0, 
        decrementCount: 0, 
        secondsPassed: 0
    });
    
    ngOnInit() {
        asyncConnect(
            this.state, 
            'secondsPassed',
            // πŸ‘‡ we can pass in a tuple [connector, deps]
            [
                interval(1000).pipe(map(tick => tick + 1)),
                // πŸ‘‡ deps is an array of keys from "state"
                // πŸ‘‡ so we can observe multiple different values from "state"
                ['count']
            ]
        );
    }
    
    onIncrement() {
        /* ... */
    }
    
    onDecrement() {
        /* ... */
    }
}

That's it! Now the interval that is used to update secondsPassed will be re-new whenever count changes.

asyncEffect

Invoke and clean up a side-effect.

// `interval()` will be subscribed upon invoking
// and will be unsubscribed when `stateProxy` is destroyed
asyncEffect(stateProxy, interval(1000), (tick) => {
    console.log(tick);// 0 1 2 3 ...
});

asyncActions

Create a AsyncActionsProxy to hold executable actions with their respective observables.

Considering the following case where you'd need to listen to trigger some effects based on some UI actions; like loadProduct, createProduct etc...

One approach is to create different Subject and expose their next method via a readonly class member.

// before
export class ProductStore {
    state = state({products: [] as Product[]});

    private $loadProduct = new Subject<void>();
    private $createProduct = new Subject<Product>();
    /* a couple more for $updateProduct and $deleteProduct */

    readonly loadProduct = this.$loadProduct.next.bind(this.$loadProduct);
    readonly createProduct = this.$createProduct.next.bind(this.$createProduct);

    /* a couple more for updateProduct and deleteProduct */


    init() {
        asyncEffect(this.state, this.$loadProduct.pipe(
            switchMap(/*...*/)
        ));

        asyncEffect(this.state, this.$createProduct.pipe(
            mergeMap(/*...*/)
        ))
    }
}

export class ProductComponent {
    constructor(public productStore: ProductStore) {
    }

    ngOnInit() {
        this.productStore.loadProduct();
    }

    onCreate(product: Product) {
        this.productStore.createProduct(product);
    }
}

With asyncActions, you only need to provide an interface of the actions you need with the value type associated with each action. AsyncActionsProxy will lazily create the Subject under the hood as the properties are accessed. Nothing is initialized upon creation.

// after
export class ProductStore {
    state = state({products: [] as Product[]});

    // πŸ‘‡ a Proxy that is lazily creating (and returning) Subject as property is accessed.
    // πŸ‘‡ Nothing is created upon initialization
    actions = asyncActions<{
        loadProduct: void,
        createProduct: Product
        /* a couple more for updateProduct and deleteProduct, as type */ 
    }>();

    init() {
        asyncEffect(this.state, this.actions.loadProduct$.pipe(
            switchMap(/*...*/)
        ));

        asyncEffect(this.state, this.actions.createProduct$.pipe(
            mergeMap(/*...*/)
        ))
    }
}

export class ProductComponent {
    constructor(public productStore: ProductStore) {
    }
    
    ngOnInit() {
        this.productStore.actions.loadProduct();
    }
    
    onCreate(product: Product) {
        this.productStore.actions.createProduct(product);
    }
}
Clone this wiki locally