-
Notifications
You must be signed in to change notification settings - Fork 6
Bang Usages
ngx-bang
has two different workflows for two different state management strategies:
- Component
- Component Provider
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.
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).
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;
}
}
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.
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;
}
}
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();
}
}
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() {
/* ... */
}
}
ngx-bang
exports a method watch()
. As the name implies, you can use watch()
to watch for state changes.
watch()
is similar toeffect()
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);
})
}
}