The Essence of Computed Properties: Derived State & Optimization
At their core, computed properties (or "derived state") are values that are calculated from other existing reactive data. They offer two primary benefits:
- Readability and Maintainability: They allow you to define complex logic once and reuse it across your templates or other computations, making your code cleaner.
- Performance Optimization: This is the "under the hood" part. They intelligently cache their results and only re-evaluate when their dependencies change, avoiding unnecessary recalculations. This efficiency is critical for minimizing re-renders and improving application responsiveness.
Let's explore how Vue and Angular (via RxJS and the newer Signals) achieve this.
Vue.js: computed
Property
Vue's computed
property is a cornerstone of its reactivity system. It leverages a dependency-tracking mechanism that Vue builds when a computed property is accessed.
How it Works Under the Hood:
- Lazy Evaluation: A computed property's value is not calculated immediately when the component is created. It's only evaluated the first time it's accessed.
- Dependency Tracking: When a computed property is accessed for the first time (and thus evaluated), Vue's reactivity system steps in. Any reactive data (like
data
properties,props
, or other computed properties) that are read during the computation of the computed property are automatically registered as its "dependencies." - Caching (Dirty Checking):
- Vue stores the result of the computed property's evaluation.
- It also keeps track of whether any of its registered dependencies have changed since the last evaluation.
- If the computed property is accessed again and its dependencies have not changed, Vue returns the cached value immediately without re-running the computation function.
- If any of its dependencies have changed, the computed property is marked as "dirty." The next time it's accessed, Vue will re-run the computation function, update the cache, and then return the new value.
- Minimizing Re-renders: This caching mechanism is crucial. Components that rely on a computed property will only re-render if the value of the computed property actually changes, not just if its dependencies change. This prevents unnecessary DOM updates (and thus repaints/reflows).
Example:
// Vue.js
const app = Vue.createApp({
data() {
return {
firstName: 'John',
lastName: 'Doe'
};
},
computed: {
fullName() { // 'fullName' is a computed property
console.log('Computing fullName...'); // This only runs when needed
return this.firstName + ' ' + this.lastName;
}
},
template: `
<div>
<p>First Name: {{ firstName }}</p>
<p>Last Name: {{ lastName }}</p>
<p>Full Name: {{ fullName }}</p> <button @click="firstName = 'Jane'">Change First Name</button>
</div>
`
}).mount('#app');
When firstName
changes, fullName
is marked dirty. The next time fullName
is accessed (e.g., in the template), it re-evaluates.
Angular: RxJS pipe(map)
and Signals computed()
Angular, traditionally, relies heavily on RxJS for reactive programming. More recently, Angular introduced Signals, which provide a more fine-grained reactivity system directly into the framework. Both can be used to derive state.
1. RxJS pipe(map)
(Observable-based Computed)
While not explicitly called "computed properties," deriving state from RxJS Observables is a very common pattern in Angular, especially with the NgRx state management library.
How it Works Under the Hood:
- Stream Transformation: You
pipe
an existing Observable (e.g., from an RxJSBehaviorSubject
or an NgRxselect
statement) and usemap
to transform its value. - Subscription-Based: The derived value only exists when something
subscribes
to the new Observable. Themap
operator itself is a pure function that defines the transformation. - Push-Based: When the source Observable emits a new value, the
map
operator processes it, and the new derived value is "pushed" to all active subscribers. - Manual Memoization (Optional): RxJS itself doesn't inherently cache computed values in the same way Vue's
computed
does. If you want to prevent re-emissions when the derived value hasn't actually changed, you need to explicitly use operators likedistinctUntilChanged()
. - Zone.js and Change Detection: When a new value is pushed, it typically triggers Angular's Zone.js to run change detection across the relevant parts of the application, potentially leading to re-renders. Without
distinctUntilChanged()
, even if the derived value is the same, an emission from the source Observable could still cause change detection.
Example:
// Angular (RxJS)
import { Component } from '@angular/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-user-profile',
template: `
<div>
<p>First Name: {{ firstName$ | async }}</p>
<p>Last Name: {{ lastName$ | async }}</p>
<p>Full Name: {{ fullName$ | async }}</p>
<button (click)="changeFirstName()">Change First Name</button>
</div>
`
})
export class UserProfileComponent {
firstName$ = new BehaviorSubject('John');
lastName$ = new BehaviorSubject('Doe');
fullName$ = combineLatest([this.firstName$, this.lastName$]).pipe(
map(([firstName, lastName]) => {
console.log('Computing fullName via RxJS...'); // Runs on every source emission
return `${firstName} ${lastName}`;
}),
distinctUntilChanged() // Only emits if fullName actually changes
);
changeFirstName() {
this.firstName$.next('Jane');
}
}
Here, fullName$
will re-compute whenever firstName$
or lastName$
emit, but thanks to distinctUntilChanged()
, it will only emit a new value to its subscribers if the computed string actually changes.
2. Signals computed()
(Newer Angular Feature)
Angular Signals introduce a new reactivity primitive that is much closer in concept and optimization to Vue's computed
.
How it Works Under the Hood:
- Explicit Dependency Tracking (Automatic): When you define a
computed
signal, Angular automatically tracks any other signals that are read within its computation function. - Lazy Evaluation: Similar to Vue, a
computed
signal's value is not eagerly calculated. It's only evaluated the first time it's accessed after its creation or after it has been marked as dirty. - Memoization (Built-in Caching):
- Angular stores the last computed value.
- If a
computed
signal is read again and none of its dependencies have changed, it returns the cached value directly without re-executing the computation function. - If any of its dependencies have changed, it's marked as "dirty." The next time it's read, it re-executes, updates its cached value, and then returns it.
- Fine-Grained Reactivity: Signals enable Angular to update the DOM more precisely. Components (or parts of templates) that depend on a signal will only update when that specific signal's value changes, avoiding broad change detection cycles. This is a significant shift from Zone.js-based change detection.
Example:
// Angular (Signals)
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-user-profile-signals',
template: `
<div>
<p>First Name: {{ firstName() }}</p>
<p>Last Name: {{ lastName() }}</p>
<p>Full Name: {{ fullName() }}</p> <button (click)="changeFirstName()">Change First Name</button>
</div>
`
})
export class UserProfileSignalsComponent {
firstName = signal('John');
lastName = signal('Doe');
// 'fullName' is a computed signal
fullName = computed(() => {
console.log('Computing fullName via Signals...'); // Only runs when dependencies change AND it's accessed
return `${this.firstName()} ${this.lastName()}`;
});
changeFirstName() {
this.firstName.set('Jane');
}
}
When firstName
is set, fullName
is marked dirty. When fullName()
is next called (e.g., by the template), it re-computes and updates the UI.
Under the Hood Comparison:
Feature | Vue.js computed |
Angular RxJS pipe(map) |
Angular Signals computed() |
---|---|---|---|
Paradigm | Option-based or Composition API | Observable/Stream-based | Signal-based |
Dependency Tracking | Automatic (during access) | Manual (explicit subscription & piping) | Automatic (during access) |
Evaluation Strategy | Lazy & Cached (pull-based reactivity) | Eager & Push-based (transforms on emission) | Lazy & Memoized (pull-based reactivity) |
Caching/Memoization | Built-in (dirty checking) | Manual (distinctUntilChanged() ) |
Built-in |
Re-execution Trigger | When accessed and dependencies changed | Every time source Observable emits | When accessed and dependencies changed |
Change Detection Impact | Component re-renders when value changes | Often triggers Zone.js change detection | Fine-grained, avoids Zone.js re-runs |
Developer Experience | Intuitive, declarative | Powerful, but steeper learning curve | Intuitive, more direct reactivity |
Impact on Repaint and Reflow:
All three mechanisms, when used effectively, aim to minimize direct DOM manipulations, which are the root cause of costly repaints and reflows.
- Caching/Memoization (Vue's
computed
, Angular'scomputed
signals, RxJSdistinctUntilChanged()
): By ensuring that the derived value only changes when truly necessary, these features prevent unnecessary re-renders of components that depend on them. If a component doesn't re-render, the browser doesn't need to perform layout recalculations (reflow) or pixel redraws (repaint) for that part of the UI. - Fine-Grained Reactivity (Angular Signals, Vue's VDOM diffing): Modern reactivity systems (like Angular Signals) and efficient Virtual DOM diffing (in Vue and React) further reduce the scope of DOM updates. Instead of re-rendering an entire component, they can pinpoint exactly which text nodes or attributes need updating, minimizing reflows and limiting repaints to very small areas of the screen.
In essence, computed
properties are a powerful optimization. They ensure that complex derivations are only performed when their inputs change and that components only update when their final, derived value changes, leading to smoother UI and better performance.
Stay tuned for the next episode of Daily Depth as we explore the intricate mechanisms behind modern software development!