DEV Community

Cover image for Change Detection without Change Detection
Armen Vardanyan for This is Angular

Posted on • Updated on

Change Detection without Change Detection

Original cover photo by Adi Goldstein on Unsplash.

What's the problem?

In Angular, we have the powerful change detection mechanism to help us rerender the UI when data changes.
In simple terms, this works in the following way:

  1. We assume state only changes on async events (clicks and other browser event, Promise resolve, setTimeout/setInterval )
  2. Angular uses zone.js to monkey patch async events
  3. When an async event happens, Angular calls the change detector
  4. The change detector traverses the tree of components and checks if any of the data has changed
  5. If so, it rerenders the UI

This process is overall known as change detection. Notice that the change detector will definitely be invoked in situations where no changes have been made at all, making it less efficient than we would ideally want.

We can do some optimizations, like using the ChangeDetectionStrategyOnPush to help the change detector work better. Or we can detach the change detector from some components if we know that they do not need change detection (a very rare scenario).

But can anything be done to make this work better? We know we can manually trigger the change detection process via a reference to the change detector (the ChangeDetectorRef class).

But how do we recognize when we need to manually trigger the change detection process? How do we know a property has changed? Also, how do we obtain the change detector reference outside of a component, so we can solve this problem with a generic function?

Let's try and address all of these questions using the new features provided by Angular version 14, and some JavaScript magic.

Disclaimer: the following code examples are an experiment, and I do not encourage you to try using this in production code (at least yet). But this approach is an interesting avenue to investigate

Enter Proxy objects

If you are unfamiliar with Proxy objects, as we are going to use them, let's explore them a bit. Proxy in JavaScript is a specific class, which wraps around a custom object, and allows us to define a custom getter/setter function for all properties of the wrapped object, while simultaneously from the outside world, the object looks and behaves as a usual object. Here is an example of a Proxy object:

const obj = new Proxy({text: 'Hello!'}, {
    set: (target, property: string, value) => {
        console.log('changing');
        (target as Record<string, any>)[property] = value;
        return true;
    },
    get(target, property: string) {
        // just return the state property  
        return (target as Record<string, any>)[property];
    },
});

console.log(obj.text); // logs 'Hello!'
obj.text = 'Bye!'; 
// logs 'changing' and 'World' because the setter function is called
Enter fullscreen mode Exit fullscreen mode

Now, what if we have Proxy objects in our app, which will call the change detector manually when the properties are changed? The only remaining caveat is obtaining the reference to the specific component's change detector reference. Thankfully, this is now possible with the new inject function provided in Angular version 14.

Inject?

inject is a function that allows us to obtain a reference to a specific token from the currently active injector. It takes a dependency token (most commonly a service class or something similar) as a parameter, and returns the reference to that. It can be used in dependency injection contexts like services, directives, and components. Here is a small example of how this can work:

@Injectable()
class MyService {
    http = inject(HttpClient);

    getData() {
        this.http.get('my-url'); // no constructor injection
    }
}
Enter fullscreen mode Exit fullscreen mode

Aside from this, we can also use this in other functions, provided these functions are called from DI contexts as mentioned. Read more about the inject function in this awesome article by Netanel Basal

Now, with this knowledge, next we are going to create a function that helps us ditch the automatic change detection but still use Angular (more or less) as usual.

So what's the solution?

We are going to create a function that makes a proxy of an object which manually triggers the change detection process when a property is changed. It will function as follows:

  1. Obtain a reference to the change detector of the component
  2. detach the change detector; we don't need automatic change detection
  3. using setTimeout, perform the change detection once after the function is done (so that initial state is reflected in the UI)
  4. Create a proxy from the plain object
  5. When an object property is called (get), we will just return the value
  6. When an object property is set, we will set the value and manually trigger the change detection
  7. Observe how the UI changes

Here is the full example:

function useState<State extends Record<string, any>>(state: State) {
    const cdRef = inject(ChangeDetectorRef);
    cdRef.detach(); // we don't need automatic change detection
    setTimeout(() => cdRef.detectChanges()); 
    // detect the very first changes when the state initializes
    return new Proxy(state, {
        set: (target, property: string, value) => {
            (target as Record<string, any>)[property] = value; 
            // change the state
            cdRef.detectChanges();
            // manually trigger the change detection
            return true;
        },
        get(target, property: string) {
            // just return the state property
            return (target as Record<string, any>)[property];
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

Now, let's see how this in action:

@Component({
    selector: "my-component",
    template: `
    <div>
        {{text}}
    </div>
    <button (click)="onClick()">Click me!</button>
    `
})
export class MyComponent {
    vm = useState({text: 'Hello, World!'}); // now we have a state

    onClick() {
        this.vm.text = "Hello Angular";
        // works as expected, changes are detected
    }
    get text() {
        console.log('working');
        return this.vm.text;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now this works as any other Angular component would work, but it won't be checked for changes on other change detection iterations.

Caveats

Nested plain objects

Nested object property changes won't trigger a UI update, for example

this.vm.user.name = 'Armen';
Enter fullscreen mode Exit fullscreen mode

Won't trigger change detection. Now, we can make our function recursive so that it makes a sport of "deep" Proxy
object to circumvent this constraint. Or, otherwise, we can set a new reference to the first-level object instead:

this.vm.user = {...this.vm.user, name: 'Armen'};
Enter fullscreen mode Exit fullscreen mode

I personally prefer the latter approach, because it is more explicit and does not involve nested object mutations.

Array methods

With this approach, we cannot count on functions like Array.push to update the DOM, instead we would need to do the same thing as in the previous example:

// instead of this
this.vm.item.push(item);

// we will have to do this:
this.vm.items = [...this.vm.items, item];
Enter fullscreen mode Exit fullscreen mode

Input properties

As we have detached the change detector, if the component has properties decorated with @Input(), the change detection will not be triggered and we won't see new values from the outside world. We can circumvent this using this approach:

export class MyComponent implements OnChanges {
    @Input() value = '';
    vm = useState({text: 'Hello, World!'}); // now we have a state
    cdRef = inject(ChangeDetectorRef);

    onClick() {
        // works as expected, changes are detected
        this.vm.text = "Hello Angular";
    }

    ngOnChanges() {
        // detect input changes manually
        this.cdRef.detectChanges();
    }
}
Enter fullscreen mode Exit fullscreen mode

This solves the problem, but does not look very pretty.

In Conclusion

This approach is, of course, experimental, but it provides an interesting insight into how Angular operates, and how we can make tweaks to boost performance without sacrificing code quality.

Discussion (1)

Collapse
darikahill profile image
DarikaHill

This post is really amazing for me!! Vashikaran Specialist In Nashik