In Angular, when would you replace an expensive template method with a pure pipe rather than a computed() signal, and what does each cache?
answer
- where the cache lives
- per call site versus per component
- argument identity versus signal dependencies
- only the last result
- mutation is invisible to both
basics
~20 sA pure pipe caches its last result per template usage and re-runs only when an argument changes by identity, which suits per-row transforms; a computed() caches one value per instance and recomputes when a signal it read changes.
solid answer
~40 sBoth remove work from every check, but they cache in different places. A pure pipe (pipes are pure unless declared `pure: false`) keeps its last arguments and result for each usage in each view, so `{{ row.net | money }}` inside an `@for` caches per row and re-runs only when that row's argument changes by identity. It stores one result, not a history, and reuses across components. A `computed()` lives on a component or service, is lazy and memoized, and recomputes when a signal it read changes; it fits values derived from component state, such as a whole-table total or a mapped view-model array. Choose the pipe for reusable per-value formatting in templates, `computed()` for state-derived values in signal-based components. Neither sees an in-place mutation.
code
ts · 27 linesimport { Component, Pipe, PipeTransform, computed, signal } from '@angular/core';
@Pipe({ name: 'lineTotal' })
export class LineTotalPipe implements PipeTransform {
transform(qty: number, unitPrice: number): string {
return (qty * unitPrice).toFixed(2);
}
}
interface Row { id: string; qty: number; unitPrice: number; }
@Component({
selector: 'app-price-list',
imports: [LineTotalPipe],
template: `
@for (row of rows(); track row.id) {
<div>{{ row.qty | lineTotal: row.unitPrice }}</div>
}
<strong>{{ grandTotal() }}</strong>
`,
})
export class PriceList {
readonly rows = signal<Row[]>([]);
readonly grandTotal = computed(() =>
this.rows().reduce((s, r) => s + r.qty * r.unitPrice, 0).toFixed(2),
);
}go deeper
Recall that pipes are pure unless declared otherwise and that computed() caches a value derived from signals.
Explain the two invalidation rules, argument identity per call site versus signal dependencies per owner, and why each keeps only its latest value.
Choose per case: pipes for reusable per-row transforms, computed() view models for state-derived lists, and spot stale-cache bugs from mutation or untracked fields.
Set a convention for derived state: signal view models in components, a small library of pure formatting pipes, and no impure pipes without review.
## The problem both solve Angular re-evaluates every binding each time it checks a view. A template **method** has no memoization, so an expensive one reruns on every check. Angular offers two built-in ways to make the value cached: a **pure pipe** and a **`computed()` signal**. They look interchangeable in simple demos, but they cache in different places, invalidate on different signals, and suit different shapes of data. ## How a pure pipe caches A **pipe** is a class decorated with `@Pipe` whose `transform()` is called from a template expression such as `{{ value | money:'EUR' }}`. Pipes are **pure by default**; `pure: false` opts out. - The cache is **per usage per view**: each place the pipe appears, in each view instance, remembers its own last arguments and last result. Inside an `@for`, every row's embedded view has its own entry. - Angular re-runs `transform()` only when **any argument changes by identity** (primitives by value, objects and arrays by reference). - It keeps **only the last result**. Alternating between two inputs recomputes every time; this is not a general memo table. - A **mutation** inside an object or array argument is invisible: same reference, no re-run. - An **impure** pipe (`pure: false`) runs on every check, which is exactly the cost you were trying to remove. ## How computed() caches `computed()` from `@angular/core` creates a read-only signal derived from other signals. - It is **lazy**: nothing runs until something reads it. - It is **memoized**: after the first read it returns the cached value until a signal it read during its last run changes. - Its dependencies are **tracked automatically** from the signals it actually read. A plain class field read inside it is not tracked, so changing that field does not invalidate the value. - It lives **once per owner** (component or service instance), not per template usage, and it takes no arguments. ## Side-by-side | Aspect | Pure pipe | `computed()` | |---|---|---| | Where the cache lives | Each template usage in each view | The component or service instance | | What invalidates it | A changed argument (by identity) | A change in a signal it read | | Takes per-row arguments | Yes, naturally in `@for` | No; derive a whole collection instead | | Works with non-signal data | Yes | Only signal reads are tracked | | Reuse across components | Import the pipe anywhere | Scoped to its owner | | Sees in-place mutation | No | No (unless a signal is set) | ## Choosing between them 1. **Per-value formatting in templates** (currency, units, status labels) is pipe territory: stateless, reusable, and naturally cached per row. Built-in pipes like `currency` and `date` are pure. 2. **Values derived from component state**, such as a filtered list, a grand total or a sort order, belong in `computed()`, especially in a signal-based component where inputs are already signals. 3. **Per-row values that depend on several state signals** are usually cleaner as a `computed()` that maps the rows into **view models** with the derived fields already filled in, then iterated with `@for ... track vm.id`. 4. **Non-signal inputs** (a legacy `@Input()` holding an object, data from an observable) favour a pipe, or converting the source to a signal first. ## Why per-row caching matters in a large list Consider a 500-row order list where each row shows a formatted line total. With a template method, every check of the view formats 500 values. With a pure pipe, each row's embedded view holds its own cache entry, so a check where no row object changed costs 500 identity comparisons and zero formatting. If one row's quantity is replaced, only that row's `transform()` runs. A `computed()` that maps the whole list gives the same saving between data changes, but when any row changes it recomputes every row, because it caches the list as one value. That difference rarely matters for cheap formatting and can matter for expensive per-row work. ## Common mistakes - Marking a pipe `pure: false` to "fix" stale output after mutating an array; replace the array instead. - Writing `computed(() => this.rows.filter(...))` where `rows` is a plain field; it computes once and never updates. - Expecting a pure pipe to remember many argument combinations; it keeps one. - Assuming either approach removes the cost of **rendering** many rows; they remove repeated **calculation**, not DOM work. Both are the idiomatic answer to "don't call methods in templates". The interview signal is knowing that the pipe caches by argument identity at each call site, while `computed()` caches by signal dependency on its owner.
- A pure pipe shows a stale value after code pushes a new item into the array it receives. Why, and what is the right fix?A pure pipe re-runs only when an argument changes by identity. `push()` mutates the same array, so the reference is unchanged and the cached result is reused. Replace the array with a new one (or set a signal with a new array) rather than marking the pipe `pure: false`, which would re-run it on every check.
- Why does computed(() => this.items.length) never update when items is a plain class field?`computed()` only tracks signals it reads. A plain field read is invisible to the dependency graph, so after the first calculation nothing ever invalidates the cached value. Make `items` a signal or an input signal.
saying these in an interview costs you the question
- A computed() recalculates on every change detection cycle
- A pure pipe re-runs when a property inside its object argument changes
- A pure pipe remembers results for every argument it has seen
- computed() tracks any class field it reads
- Making a pipe impure is the right fix for mutated arrays