Skip to content

Managing Component State with Angular State Hook

We build a state hook to manage component state.

Published
Updated
Reading time
4 min

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

Send me a message