Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Sara Dunnack
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Sara Dunnack

Signals And Array Mutability In Angular 18

By
Published in

Like many change detection mechanisms, Signals in Angular 18 rely on reference changes in order to trigger change detection. When consuming simple values, this is fine because simple values are passed by value, not by reference. But, when wrapping complex objects in a Signal, the mutability of objects becomes a point of consideration. It's important to understand how the change detection mechanisms in Angular work so that you don't end up falling down the immutability rabbit-hole.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

From its inception, the magic of Angular has always been that it "just works". You change a value in your view-model and your Angular view automatically updates. No wresting with immutability; no explicit calls to a setState() function; just simple, seamless, and straightforward change detection.

Of course, if your view rendering gets very complicated, "simple, seamless, and straightforward" can sometimes come with a performance penalty. In such cases, strategies like OnPush change detection and Signals can help narrow the surface area of reactivity.

But, it's important to understand that one strategy does not wholly replace another. Even if you use Signals and OnPush change detection, Angular is still going to perform some automatic change detection for you. And, embracing that—instead of fighting it—can save you from succumbing to the folly that all data should be immutable.

Aside: In extreme performance edge cases, you can use the ChangeDetectorRef to remove an entire component from the change detection tree such that Angular won't perform any automatic change detection at all. Most people will never need this.

To explore the nuance of change detection in Angular 18, I've created a component that lists 100 buttons that can be toggled on. Some important points about this component:

  • It uses OnPush change detection. This means that asynchronous actions, like HTTP calls, setTimeout(), and setInterval(), won't automatically trigger change detection of the component's internal view.

  • It wraps the array of buttons in a Signal.

  • It has two computed Signals that count the number of buttons that are currently off (false) or on (true).

  • The user can manually toggle buttons on.

Let's look at the code. First, the definition of the component with its OnPush change detection strategy and its public view-model (truncated):

interface Entry {
	value: boolean;
}

@Component({
	standalone: true,
	changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {

	public entries = signal<Entry[]>( this.buildEntries( 100 ) );
	public falseCount = computed( () => this.computeCount( false ) );
	public trueCount = computed( () => this.computeCount( true ) );

}

By default, every value in the entries collection will be false. And, each computed Signal contains the count of the number of buttons in the relevant state (false or true).

The user can manually toggle a value at a given index using the (click) handler:

@Component({ ... })
export class AppComponent {

	/**
	* I set the given entry to false.
	*/
	public toggle( index: number ) {

		// Note: We are mutating the array directly without changing the Signal reference.
		// As such, this won't trigger any downstream computing of other Signals; but,
		// since this method was triggered via a user interaction within the component, it
		// will trigger change detection.
		this.entries()[ index ].value = true;

	}

}

And, every one second we're going to randomly change one of the values to true by defining a setInterval() in the ngOnInit() life-cycle method:

@Component({ ... })
export class AppComponent {

	/**
	* I get called once after the inputs have been bound for the first time.
	*/
	public ngOnInit() {

		// Every second, we're going to randomly flip one of the values deep within the
		// entries. However, since this component uses OnPush change detection, the
		// setTimeout() won't trigger change detection and no reconciliation will happen.
		setInterval(
			() => {

				var index = Math.floor( this.entries().length * Math.random() );

				this.entries()[ index ].value = true;

				console.log( "Flipping index:", index );

			},
			1000
		);

	}

}

In both of these cases, notice that we're changing the state of the entries array without changing the Signal object reference (ie, we're not creating a new array). However, in the first case, the change is being triggered by a user interaction within the view (a click); as such, Angular will trigger automatic change-detection. And, in the second case, the change is being triggered by an external interval; as such, Angular will not trigger any automatic change detection.

Here's the HTML view for the component:

<p>
	False: {{ falseCount() }} ,
	True: {{ trueCount() }}
</p>

<p class="toggles">
	@for ( entry of entries() ; track $index ) {

		<button
			(click)="toggle( $index )"
			[class.truthy]="entry.value">
			{{ entry.value }}
		</button>

	}
</p>

<p>
	<button (click)="shallowCopy()">
		Create Shallow Copy
	</button>
</p>

Notice that we're mixing-and-matching our reference types within the view. Some of the reference are values wrapped in Signals: falseCount(), trueCount(), and entries(); and, some of the references are directive value references: entry, $index, and entry.value. Each of these reference types is managed slightly differently by Angular.

Now, let's run this Angular 18 application and click on a few buttons:

There are several important things to notice in the above GIF:

  1. When the setInterval() callback toggles a button, the view does not update. This is because the OnPush nature of the component won't respond to changes outside of the component.

  2. When I toggle one of the buttons manually, not only does the targeted button change, all of the buttons updated by the setInterval() are also re-rendered to the correct state. This is because a user-interaction, performed within the bounds of the component view, trigger change-detection on that view. This means that every entry.value and [class.truthy] expression is reconciled, regardless of how or when it was set.

  3. No matter what I do, the falseCount() and the trueCount() computed Signals are never updated. This is because I never change the actual reference to the entries collection; which, in turn, means that the computed values are never recomputed.

At the bottom of the view, there's a button that can change the entries collection by creating a shallow copy:

@Component({ ... })
export class AppComponent {

	/**
	* I perform a shallow copy of the entries collection (updating the Signal reference).
	*/
	public shallowCopy() {

		this.entries.update( entries => entries.slice() );

	}

}

Now, if I let the setInterval() run for a few seconds and then create a shallow copy, you'll see that the computed Signals are updated:

When I create a shallow copy of the entries() collection, I'm changing the internal object reference of the Signal. This gets Angular to call the compute callbacks for the falseCount() and trueCount() Signals. On top of that, since the shallow copy was triggered by a click within the view, this also triggered the automatic change-detection, which updated the rendering of the individual button states.

Change Detection Is Nuanced

You get automatic change detection from Angular whether you want it or not (unless you explicitly detach the View). So, it's not a question of choosing one or the other—it's a question of which change detection mechanism provides the right set of trade-offs in a given component context.

In this particular demo, I purposefully added mechanisms that would fail due to the direct mutation of the entries array. However, if I didn't have a setInterval() or computed values, the direct mutation of the array wouldn't have been an issue at all. There's nothing inherently wrong with mutable data; and, in the vast majority of cases, performance isn't a problem.

Want to use code from this post? Check out the license.

Reader Comments

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel