This is a subpost of React Hooks for Angular. Please read that post first to understand what Angular Hooks are.
We first take a look at how to manage component state in Angular when using OnPush Change Detection. Then we'll build a state hook to manage component state.
State without hooks
When using OnPush
Change Detection, Angular will automatically detect changes when an @Input
property has changed and after an event handler as been called.
However, Angular will not detect changes when you update state asynchronously, like in a setTimeout
handler or an observable subscription. In such cases you need to call markForCheck()
on the change detector ref.
@Component({
template: `<p>Counter: {{ counter }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleComponent implements OnInit {
counter = 0;
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
setTimeout(() => {
// Angular will not detect this change
this.counter++;
// You need to tell Angular to detect changes
this.cdr.markForCheck();
}, 1000);
}
}
In addition, you need to be careful when mutating objects. If you mutate an object that is passed to an @Input
property, Angular will not detect changes and therefore not re-render the child component.
@Component({
template: `<app-user [user]="user"></app-user>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleComponent implements OnInit {
user = { id: 1, name: "John Doe" };
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
setTimeout(() => {
// Object mutation does NOT work
// Angular will not detect this change
this.user.name = "Jane Doe";
this.cdr.markForCheck();
// Create a new object instead, this works
this.user = { ...this.user, name: "Jane Doe" };
this.cdr.markForCheck();
}, 1000);
}
}
State with State Hook
Now let's implement component state with a state hook.
Basic state
We provide the hook as provider on the component (1), inject the instance via dependency injection (2) and initialize the state by calling use()
(3):
@Component({
template: `<p>Counter: {{ state.counter }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [StateHook], // (1)
})
export class ExampleComponent {
// Create a new state object
state = this.stateHook.use({ counter: 0 }); // (3)
constructor(private stateHook: StateHook) {} // (2)
}
The state hook provides a $set
function to update the state. The function accepts a partial state object so we don't need to provide all properties.
@Component({ ... })
export class ExampleComponent implements OnInit {
// ...
ngOnInit() {
setTimeout(() => {
// Use the `$set` method to update the state
this.state.$set({ counter: this.state.counter + 1 });
}, 1000);
}
}
Calling $set
will update the state and automatically trigger a change detection.
Complex state
As we saw in the previous chapter, you should not mutate objects but instead use the spread syntax to create a new object. Angular will not detect changes in mutated objects and therefore not re-render the child component.
With the state hook, you are allowed to mutate objects by providing an update function to the $set()
method:
@Component({ ... })
export class ExampleComponent implements OnInit {
// ...
ngOnInit() {
setTimeout(() => {
this.state.$set(state => {
// You are allowed to mutate objects 🥳
state.user.name = 'Jane Doe';
});
}, 1000);
}
}
Under the hood, the state hook will automatically create a new user object with the mutated values so Angular detects that the user object has changed.
Implementation
Now let's take a look at the hook implementation. The use()
function creates a new state object with the initial state and a $set
function.
The $set()
function uses Immer to update the state. This allows the component to mutate objects without actually mutating it - Immer automatically creates a new object with the mutated values.
@Injectable()
export class StateHook {
constructor(private cdr: ChangeDetectorRef) {}
use<T extends object>(initial: T): State<T> {
const state: State<T> = {
...initial,
$set: (change) => {
// Create the producer function which updates the state
const producer =
typeof change === "function"
? (draft: T) => void change(draft)
: (draft: T) => void Object.assign(draft, change);
// Use Immer to create the new state
const newState = produce(state, producer);
// If the state has not changed,
// do not trigger change detection
if (newState === state) return;
// Update the state
Object.assign(state, newState);
// Trigger change detection
this.cdr.markForCheck();
},
};
return state;
}
}
Limitations
It's highly recommended to create a single state object per component (similar to the state object in React class components):
// NOT RECOMMENDED
user = this.stateHook.use({ id: 1, name: "John Doe" });
tasks = this.stateHook.use([]);
// RECOMMENDED
state = this.stateHook.use({
user: { id: 1, name: "John Doe" },
tasks: [],
});
The reason for this is that the hook will mutate the root state object (user, tasks, state) and therefore Angular would detect changes in the root object when you pass it to a child.
<!-- Don't pass the root object to child components -->
<app-some-child [user]="user"></app-some-child>
<app-some-child [tasks]="tasks"></app-some-child>
<app-some-child [data]="state"></app-some-child>
<!-- Do this instead -->
<app-some-child [user]="state.user"></app-some-child>
Demo
Try a simple demo of the hook:
angular-hooks.blog.sandroroth.com
The full source code is available on GitHub:
@rothsandro/angular-hooks