How to use changeDetectionStrategy.onPush to keep object correctly updated?

836 views Asked by At

I'm displaying a list of tasks in a todo with an ngFor and a checkbox to toggle the task status. I'm using store from ngrx with the following state interface:

export interface State {
    tasks: Task[];
    index: { [_id: string]: Task };
};

That's the reducer which is in charge of updating the task when the status change:

case TaskActions.UPDATE_TASK_SUCCESS: {
    const task = action.payload;
    state.index[task._id] = Object.assign(state.index[task._id], task);

    return state;
}

And here is the task component which take the task as @Input and has changeDetectionStrategy set to onPush:

import { Component, OnInit, Input, ChangeDetectionStrategy, DoCheck } from '@angular/core';

import {Store} from '@ngrx/store';
import { AppState } from '../../../../domain';

import { TaskActions } from '../../../../domain/actions';

@Component({
    selector: 'task',
    templateUrl: './task.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush,
    styleUrls: ['./_task.component.scss']
})
export class TaskComponent implements OnInit, DoCheck {

    @Input() task;

    constructor(
        private store: Store<AppState>,
        private TaskActions: TaskActions
    ) { }

    ngOnInit() {
    }

    ngDoCheck() {
        console.warn('Check');
    }

    public toggleTaskStatus(_task) {
        var new_task = Object.assign({}, _task, {['status']: (_task.status == 'todo' ? 'done' : 'todo')});
        this.store.dispatch(this.TaskActions.updateTask(new_task));
    }

}

With this configuration, the first time I click on the checkbox the task inside the store gets updated, but the UI doesn't reflect any change. From the second time instead, the UI start toggling its status but not according the store state (when task status is 'todo' in the store, the UI is 'done'). Moreover, to get updated the UI doesn't wait for the success action in the reducer (the service is creating an observable with a slight timeout of 1 second).

When changeDetectionStrategy is set to default, everything seems working correctly, with the UI updating according to store state.

What am I doing wrong? Is regarding the immutability of the state or some issues inside the component?

1

There are 1 answers

0
maxime1992 On BEST ANSWER

A reducer MUST be immutable.

Here :

case TaskActions.UPDATE_TASK_SUCCESS: {
    const task = action.payload;
    state.index[task._id] = Object.assign(state.index[task._id], task);

    return state;
}

You're mutating the state.

This line of code changeDetection: ChangeDetectionStrategy.OnPush tells Angular that if the @Input has the same address in memory (reference), it should not be updated (to improve performance).

In your case, you mutate the state and return the same (state) object.
That's why it's working when you remove the line changeDetection: ChangeDetectionStrategy.OnPush.

To make it work, you should rather have :

case TaskActions.UPDATE_TASK_SUCCESS: {
  const task = action.payload;

  // here, we return a new reference and ensure immutability
  return Object.assign(
    {},

    state,

    {
      index: {
        [task._id]: Object.assign(state.index[task._id], task)
      }
    }
  )
}

To avoid mutating the state, you might want to setup ngrx-store-freeze. It will throw an error if a state mutation occurs.


Just to speak a little bit about ES7 syntax, Typescript now supports the spread operator on objects and we might also have this syntax :

case TaskActions.UPDATE_TASK_SUCCESS: {
  const task = action.payload;

  return {
    {},

    ...state,

    {
      index: {
        [task._id]: { ...state.index[task._id], task }
      }
    }
  }
}

But Angular doesn't support Typescript 2.1.4 for now and using this version will break your app. It'll hopefully be supported soon. CF that PR. (thanks to semver, it should land in Angular 4.0 in march 2017).