Reactive Input state for Angular ViewModels
Update July 2024: For the latest and greatest on state management, please check this article: Modern Angular State Management with Signals
Update: 10 November 2023
With Angular Signals being out of developer preview in version 17 we could use setters with signals as well. Beware that signal inputs are coming as well.
private readonly itemsPerPageSignal = signal(0);
private readonly totalSignal = signal(0);
private readonly pageIndexSignal = signal(0);
@Input() public set itemsPerPage(v: number) {
this.itemsPerPageSignal.set(v);
};
@Input() public set total(v: number {
this.totalSignal.set(v);
};
@Input() public set pageIndex(v: number {
this.pageIndexSignal.set(v);
};
private readonly viewModel = computed(() => {
return {
total: this.totalSignal(),
previousDisabled: this.pageIndexSignal() === 0,
nextDisabled: this.pageIndexSignal() >= Math.ceil(this.totalSignal() / this.itemsPerPageSignal()) - 1,
itemFrom: this.pageIndexSignal() * this.itemsPerPageSignal() + 1,
itemTo:
this.pageIndexSignal() < Math.ceil(this.totalSignal() / this.itemsPerPageSignal()) - 1
? this.pageIndexSignal() * this.itemsPerPageSignal() + this.itemsPerPageSignal()
: this.totalSignal(),
};
});
public get vm() {
return this.viewModel();
}
Below is the article that makes sense for all Angular versions < 16.
Explaining the problem
In a previous article, we have written about Reactive ViewModels for Ui components in Angular. If you haven’t read this article yet, we recommend you read it first, since this article is an improvement on the other one. With a ViewModel, we mean a Reactive model specifically created for the view (template). In short, it is an RxJS Observable that contains all the properties that our template needs to render correctly. It contains all the properties and only those properties.
Some of the advantages are:
- Only one async pipe is needed.
- Only one subscription is created.
- No more null issues with the async pipe.
- Clear separation of concerns.
- We can move logic from the template to the ViewModel of the class instance.
Here we can see some short code for a ViewModel of a pager component:
(Pay attention to the single subscription in the *ngIf
directive)
<ng-container *ngIf="vm$|async as vm">
Showing {{ vm.itemFrom }}
to {{ vm.itemTo }} of
{{ vm.total }} entries
...
</ng-container>
In the article mentioned before, we used a combination of setters and BehaviorSubjects to create observables from our @Input() properties. We then used the combineLatest
operator to create our ViewModel and used the distinctUntilChanged
operator to optimize it for performance.
While this can be seen as a nice approach, there are still a few problems with this way of creating ViewModels:
- Setters of inputs can be called multiple times in one Change Detection cycle.
- This can result in multiple emissions of the
combineLatest
operator in one Change Detection cycle. - This can result in multiple calculations of the ViewModel in one Change Detection cycle.
- This will result in multiple
markForCheck()
executions in one Change Detection cycle due to theasync
pipe, even though this is a trivial performance loss.
- This can result in multiple emissions of the
- Creating a setter and a BehaviorSubject for every @Input() property results in boilerplate code.
- Manual
combineLatest()
withdistinctUntilChanged
results in boilerplate and RxJS complexity. This could be automated.
The fact that the setters can be called multiple times per Change Detection cycle could be fixed by using a debounceTime(0)
statement:
public readonly vm$: Observable<ViewModel> = combineLatest({...}).pipe(
debounceTime(0), // Combine events that happen at the same time
map(({ itemsPerPage, total, pageIndex }) => {
...
})
);
This approach also has its drawbacks since it results in:
- Unnecessary boilerplate code
- A new Change Detection cycle is being triggered. (added to the macrotask queue of the javascript event loop)
- It’s a hack…
The goal of this article
In this article, we will create one observable that is populated with the values of the @Input() properties. We will continue on the example of the previous article which is a pager component. What we want to achieve is the following:
- The Observable should emit even if there are Inputs that are not being set (Optional inputs).
- The Observable should emit only once per Change Detection cycle.
- The Observable should emit initially with the initial values
- It should work with default values of @Input() properties
- The Observable should emit after initialization only if one of the inputs has changed.
- We want to remove as much boilerplate code as possible
In this article, we will cover the creation of an InputStateModel and we will see how we can remove boilerplate by using Typescript Decorators in a second version. This is the first article of a series of multiple articles: In the next article, we will dive into the component state and later on we will see how we can combine the Input state, the component state and ViewModels in Angular.
The ngOnChanges life cycle hook
Let’s focus on passing the input state into our InputStateModel which would hold one observable that is emitted for every @Input() property change.
The default way to know if any @Input() properties have gotten new data in Angular would be the use of the ngOnChanges
lifecycle hook. This lifecycle hook gets a SimpleChanges
parameter passed that contains the current changes and the previous changes for every input. The beautiful thing about this hook is that it only gets executed once per Change Detection cycle.
Using this lifecycle hook can get brittle because it is not typed. Let’s create our own TypedSimpleChanges
interface that takes a generic type to fix this.
Let’s add a PagerInputState
type as well to complete it.
// Interface for typed simple changes
export interface TypedSimpleChanges<T> extends SimpleChanges {}
// Type for the Observable that will hold the input state
type PagerInputState = {
itemsPerPage: number;
total: number;
pageIndex: number;
}
export class PagerComponent implements OnChanges {
...
public ngOnChanges(changes: TypedSimpleChanges<PagerInputState>): void {}
}
In the ngOnChanges
life cycle hook we want to feed some kind of Observable with data that we can consume or that we can generate a ViewModel from.
We want to create an InputStateModel
class that is an injectable that is provided on PagerComponent
. We want to inject it into the pager component but we also want it to be destroyed when the PagerComponent
gets destroyed. When we provide the InputStateModel
in the providers
as shown below, the ngOnDestroy
lifecycle hook of InputStateModel
will get executed when PagerComponent
gets destroyed. In other words: The instance of InputStateModel
will get destroyed when the instance of PagerComponent
gets destroyed. We can also see that we use the ngOnChanges
life cycle hook to pass the simple changes to the update()
function of this.inputStateModel
.
@Component({…
// Create new instance of InputStateModel
// That is tied to the instance of PagerComponent
providers: [InputStateModel]
})
export class PagerComponent implements OnChanges {
// Inject the InputStateModel of type PagerInputState
private readonly inputStateModel = inject(InputStateModel<PagerInputState>);
// Extract the state from the model
private readonly inputState$ = this.inputStateModel.state$;
@Input() public itemsPerPage: number = 0;
@Input() public total: number = 0;
@Input() public pageIndex: number = 0;
// Update the type to TypedSimpleChanges that is generic
public ngOnChanges(changes: TypedSimpleChanges<PagerInputState>): void {
// Pass the changes to the inputStateModel that will handle everything.
this.inputStateModel.update(changes);
}
// generate ViewModel from the inputState$
public readonly vm$: Observable<ViewModel> = this.inputState$.pipe(
map(({ itemsPerPage, total, pageIndex }) => {...})
);
}
The code above is the first version of how we can consume the InputStateModel
. We still have to implement the InputStateModel
later, but we can see that we have reduced the boilerplate code a lot:
- There are no more BehaviorSubjects.
- There ar no more setters.
- There are no more
combineLatest()
nordistinctUntilChanged()
operators to be seen. - We can just use the
map()
operator to create thevm$
ViewModel fromthis.inputState$
.
Most importantly: It will only be called once per Change Detection cycle.
Implementing InputStateModel
Before we start implementing this Injectable, let’s list the features of this class:
- It should create a BehaviorSubject that holds the input state when the
update
function is called for the first time. - It should update that BehaviorSubject when the
update
function is called afterward. - It should cancel the subscriptions on the
ngOnDestroy
life cycle hook. - It should only emit new values when one or more @Input() properties have changed.
Let’s dive in, the explanation of the code is added in the comments:
@Injectable()
export class InputStateModel<T> implements OnDestroy {
// This injectable will get destroyed when the component who
// provides this instance is destroyed, We will use the takeUntil operator
// to cleanup every subscription to state$
private readonly destroy$$ = new Subject<void>();
// The update function will create the state$$ BehaviorSubject
// We will use this initialized$$ BehaviorSubject to only expose state
// when this instance is initialized
private readonly initialized$$ = new BehaviorSubject<boolean>(false);
// This will be created and nexted in the update function
// This holds the value of all the @Input() properties
private state$$: BehaviorSubject<T>|undefined;
// Expose a state$ observable when this instance is initialized
public readonly state$: Observable<T> = this.initialized$$.pipe(
filter(v => !!v), // Only expose the state when initialized
switchMap(() => {
// This is here to avoid Typescript compilation issues
if(!this.state$$){
throw new Error('State must be initialized. Did you forgot to call the update method?')
}
return this.state$$
}),
// Only emits when one or more of the @Input() properties change
distinctUntilChanged((previous: T, current: T) => {
const keys = Object.keys(current);
return keys.every(key => {
return current[key] === previous[key]
})
}),
// Clean up after ngOnDestroy
takeUntil(this.destroy$$),
)
// Next the destroy$$ subject when this instance gets destroyed
// This is used to avoid memory leaks
public ngOnDestroy(): void {
this.destroy$$.next();
}
/**
* Will be called from within the ngOnChanges
* life cycle hook
**/
public update(changes: TypedSimpleChanges<T>):void {
const keys = Object.keys(changes);
// If the state$$ BehaviorSubject is not created yet
// (initial ngOnChanges):
// Create a state for all @Input() properties
// Create a new BehaviorSubject with that state,
// and set the initialized$$ to true
if (!this.state$$) {
const state: T = {} as T;
keys.forEach((key) => {
state[key as keyof T] = changes[key].currentValue;
});
this.state$$ = new BehaviorSubject<T>(state);
this.initialized$$.next(true);
// If the state already exists:
// Take the current state and only update the state with inputs
// if the current value is different from the previous value
} else {
const state: T = { ...this.state$$?.value } as T;
keys.forEach((key) => {
if (changes[key].currentValue !== changes[key].previousValue) {
state[key as keyof T] = changes[key].currentValue;
}
});
// Only create one event for all @Input() properties
this.state$$.next(state);
}
}
}
That’s it. We can find a working Stackblitz example of this code here.
Optimizing with a Typescript Decorator
The current simplified version of the PagerComponent still has some boilerplate code we would like to reduce:
@Component({…
providers: [InputStateModel] // Boilerplate
})
export class PagerComponent implements OnChanges {
// Boilerplate
private readonly inputStateModel = inject(InputStateModel<PagerInputState>);
private readonly inputState$ = this.inputStateModel.state$;
// Boilerplate
public ngOnChanges(changes: TypedSimpleChanges<PagerInputState>): void {
this.inputStateModel.update(changes);
}
}
- We have to provide the
InputStateModel
in theproviders
property of@Component
. - We have to inject the
inputStateModel
and then extract thestate$
property from it. - We have to implement
ngOnChanges
and updatethis.inputStateModel
in there with the latest changes. - This implementation does not work with default values of @Input() properties.
We would really love to reduce this to a one-liner that listens to ngOnChanges
, ngOnDestroy
but would still let us implement it in the pager component if we wanted to.
We could create an @InputState()
decorator that does this for us and we would like to use it like this:
@Component(...)
export class PagerComponent {
@Input() public itemsPerPage: number = 0;
@Input() public total: number = 0;
@Input() public pageIndex: number = 0;
@InputState() private readonly inputState$!: Observable<PagerInputState>;
}
You can see the !
syntax that will tell us that this property is always initialized because it would be the responsibility of the @InputState()
decorator to initialize that observable.
This Decorator would use a variant of the InputStateModel
to achieve everything we have accomplished before.
When using Typescript property decorators it is important to realize that this decorator applies to the prototype of the class and not to the instance that is created of that class.
Let’s create an input-state-decorator.ts
file and expose the InputStateModel
:
export function InputState<T>() {
return function (
target: any,
key: string
) {
// This secretModel will actually be kept as a property on the instance of
// the component that uses this InputState decorator
const secretInputModel = `secret${key}Model`;
// This accessor is used to get access to the secretInputModel
// This property will not exist on the instance of the component
// that uses this InputState decorator
const accessorInputModel = `accessor${key}Model`;
// We need to keep InputStateModel on the instance
// Since this decorator is static we need to use this syntax
// to get access to this
Object.defineProperty(target, accessorInputModel, {
get: function () {
// If it doesn't exist yet, create the InputStateModel
if (!this[secretInputModel]) {
this[secretInputModel] = new InputStateModel();
}
// return the InputStateModel
return this[secretInputModel];
},
});
// This is what the decorator will return
// (the actual input state of the InputStateModel)
return {
get: function () {
return this[accessorInputModel].state$;
}
};
};
}
Getting access to this
in a Typescript property decorator seems a bit tricky and since this is not a deep dive into Typescript decorators we suggest having a look at the documentation if you would like to know more. We are always open to questions, just leave them in the comments.
In short: This decorator will keep an instance of InputStateModel
on the instance of the component where the InputState
decorator is used and will return the state$
property of that InputStateModel
instance to the property where this decorator is used on.
Now let’s handle the ngOnChanges
and ngOnDestroy
logic. We will use the ngOnChanges
life cycle hook to populate the InputStateModel
and ngOnDestroy
to clean it up:
// Keep track of the original 2 lifecycle hooks
const origNgOnChanges = target.constructor.prototype.ngOnChanges;
const origNgOnDestroy = target.constructor.prototype.ngOnDestroy;
// overwrite the original ngOnChanges life cycle hook
target.ngOnChanges = function (simpleChanges: TypedSimpleChanges<T>): void{
// if ngOnChanges is implemented execute it as well
if (origNgOnChanges) {
origNgOnChanges.apply(this, [simpleChanges]);
}
this[accessorInputModel].update(simpleChanges); // send changes to model
};
// Overwrite the original ngOnDestroy life cycle hook
target.ngOnDestroy = function (): void {
// If ngOnDestroy is implemented execute it too
if (origNgOnDestroy) {
origNgOnDestroy.apply(this, []);
}
// Clean up the instance InputStateModel
this[accessorInputModel].ngOnDestroy();
};
This will work but we have one more problem: This will not work with the default values of the inputs, since the ngOnChanges
does not contain the default values of our @Input() properties.
To fix this, we will also need to hook into the ngOnInit
lifecycle hook where we will feed the InputStateModel
with the default values. In this sample, we see how we keep track of the old implementation of the ngOnInit
life cycle hook and hook into it to set the InputStateModel
with the default values that we can find through this.constructor.ɵcmp.inputs
.
const origNgOnInit = target.constructor.prototype.ngOnInit;
// overwrite the origin ngOnInit life cycle hook
target.ngOnInit = function(): void{
const simpleChangesToPass: TypedSimpleChanges<T> = { };
Object.keys(this.constructor.ɵcmp.inputs)
.map(key => this.constructor.ɵcmp.inputs[key])
.forEach((inputKey) => {
simpleChangesToPass[inputKey] = new SimpleChange(
this[inputKey],
this[inputKey],
true
);
});
this[accessorInputModel].update(simpleChangesToPass);
// if ngOnChanges is implemented execute it as well
if (origNgOnInit) {
origNgOnInit.apply(this);
}
}
The decorator is finished and now we can create a ViewModel from our inputs without any boilerplate code:
export class PagerComponent {
// Create type safe observable that is updated and cleaned up automatically
@InputState() private readonly inputState$!: Observable<PagerInputState>;
@Input() public itemsPerPage: number = 0;
@Input() public total: number = 0;
@Input() public pageIndex: number = 0;
// Use the inputState$ to calculate a ViewModel
public readonly vm$: Observable<ViewModel> = this.inputState$.pipe(
map(({ itemsPerPage, total, pageIndex }) => {
return {... };
})
);
...
}
You can check a fully working example in this stackblitz.
Why did we write this ourselves?
- We want to update to newer Angular versions without any trouble
- We can add features ourselves, like snapshots for instance
- It’s quite simple and we have full control over the code
- It’s not that much code
- We are not diverging from the Angular ecosystem
- We have learned a bunch
- We are in charge of our own quality
Conclusion
- We have reduced the boilerplate code a lot.
- We have hidden some RxJS complexities for the developer.
- We now have an optimized @Input() properties state observable that we can easily use to create a ViewModel.
- We don’t have unwanted emissions or calculations.
- A Typescript property decorator is static, and we have to use a specific syntax to get a hold of
this
. - Subscriptions are handled on
ngOnDestroy
and cleaned up automatically. - We can still implement the
ngOnChanges
andngOnDestroy
life cycle hooks if we want to but we don’t have to. - We don’t rely on any open-source projects and are working close to the Angular ecosystem
- In angular 17, signal inputs will arrive which will probably result in something like this:
export class PagerComponent {
public itemsPerPage = input(0);
public total = input(0);
public pageIndex = input(0);
public inputState = computed(() => ({
itemsPePage: this.itemsPerPage(),
total: this.total(),
pageIndex: this.pageIndex(),
}))
}
There are still a few problems though. In the next article, we will fix the following problems:
- Our ViewModel still contains properties our template doesn’t need: (
pageIndex
anditemsPerPage
) - If we set the @Input() properties ourselves, the state will not update
- The observable gets a new event every time an input property changes (in the same Change Detection cycle of course)
- How can we make this work together with other pieces of state.
This is the first step towards truly reactive components. In the next articles, we will dive deeper and use this principle as a cog in our reactive component state. If you liked the article, please leave a comment! Check out this article if you want to learn some Angular State management Best practices
When input state is not enough and our component needs local state (or even global state) as well. We would love you to take a look at ObservableState.
If you like to learn directly from me, check out my Angular Training and Angular Coaching
Follow @TwitterDevJoin the club and get notified with new content
Get notified about our future blog articles by subscribing with the button below.
We won't send spam, unsubscribe whenever you want.
Update cookies preferences