Practical Use Case: Server-Side Calculations
Imagine a search field where users type a query, and the application fetches matching records from a backend. For every record the user selects, additional computed fields must be displayed in a column labeled custom. Since that computation happens remotely, each selector requires an HTTP request. A pipe is the natural fit here, encapsulating the API call inside its transformation logic.
The code driving the animation above looks like this:
// ... imports ...
@Component({
selector: 'app-example-pipe',
template: `
<h2>Pipe call component</h2>
<!-- search anime -->
<app-search-anime [formControl]="animeSearchControl" />
<!-- table header -->
<app-table-header />
<!-- table body -->
<div *ngFor="let data of loadedAnime$ | async" class="...">
<div>{{ data.title_english ?? data.title }}</div>
<div>{{ data.source }}</div>
<div>{{ data.duration }}</div>
<div>{{ data.score }}</div>
<div>{{ data | hardMathEquasionPipe | async }}</div>
</div>
`,
styles: [],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ /* ... imports ... */ ],
})
export class ExamplePipeComponent {
private apiService = inject(ApiService);
animeSearchControl = new FormControl<AnimeData>(
{} as AnimeData, { nonNullable: true }
);
loadedAnime$ = this.animeSearchControl.valueChanges.pipe(
scan((acc, curr) => [...acc, curr], [] as AnimeData[])
);
}
The hardMathEquasionPipe is responsible for dispatching the request to the server, retrieving the computed data for each item in the list.
@Pipe({
name: 'hardMathEquasionPipe',
standalone: true,
})
export class HardMathEquasionPipe implements PipeTransform {
private apiService = inject(ApiService);
transform(anime: AnimeData): Observable<number> {
console.log(`Pipe running for ${anime.title}`);
return this.apiService.hardMathEquasionAsync(anime);
// ^^ API request to the server
}
}
This is all standard Angular territory. The real question is: what prevents this pipe from firing off a new HTTP request on every single change detection cycle? To get to the bottom of it, we have to see what the compiler does with our component and its pipe during the build.
Inspecting the Compiled Output
To keep the generated code comfortable to read, the build command needs a few flags:
ng build --output-hashing=none --optimization=false --named-chunks=true
After the build finishes, the main.js bundle should contain the ExamplePipeComponent function. That component is the one that embeds HardMathEquasionPipe inside its template.
Note: The minifier prefixes internal functions with the marker \u0275\u0272, which has been stripped out here to improve readability.
The component function branches on the renderer flag (rf). The first branch (rf & 1) runs only once, during the initial creation of the component. The second branch (rf & 2) is invoked on every change detection pass. For a deeper understanding of these flags, the article Overview of Angular’s Change Detection operations in Ivy is a good resource.
The crucial part of the change detection branch is this expression:
textInterpolate(pipeBind1(12, 7, pipeBind1(11, 5, data_r1)))
This line is the core of the mechanism. With each change detection cycle, the framework invokes the pipe’s logic:
pipe(11, "hardMathEquasionPipe")
Then it takes the value returned by the pipe and writes it into the DOM via the textInterpolate call. The numeric token 11 acts as a linkage between the pipe factory and the pipeBind1 call.
Tracing the Internal Functions
Looking at the Angular runtime code, the function named pipeBind1 is the one doing the heavy lifting. Its job is to attach the pipe instance (the hardMathEquasionPipe) to whatever data is currently bound in the template expression — for this example, that is the selected anime entity. Since it is an internal helper, its implementation is consistent across applications:
Since the pipe is pure by declaration — and the default behavior for all pipes is pure — the runtime routes the call through the function pureFunction1Internal. Its source is exactly what you would expect:
Inside this helper, the function bindingUpdated gets called on every change detection run. It performs a comparison: is the incoming value passed to the pipe identical to the value from the last invocation?
If a difference is detected, the code path goes through updateBinding. This recomputes the pipe’s transformation logic and subsequently refreshes the DOM with the newly returned result from hardMathEquasionPipe.
But in the scenario at hand, the input (the anime data object) stays the same throughout. That makes the alternative function — getPureFunctionReturnValue — the one that matters:
This second function essentially retrieves the previously computed result from the pipe’s cache and hands it over for rendering. The view update happens regardless of the branch taken: the framework always calls textInterpolate with whatever value ended up being selected.
Putting the Pieces Together
So the end result is nuanced. On one hand, pipes technically run their logic on every change detection cycle driven by user interactions. On the other hand, the runtime performs a strict comparison between the current and previous arguments. If all arguments remain the same, the pipe simply returns its prior output from memory.
Only when an argument actually changes does the transformation logic run again. In both scenarios, the textInterpolate(...) call executes, making sure the DOM receives the final value.
The following snippet shows the implementation of the textInterpolate() function:
function textInterpolate1(prefix, v0, suffix) {
const lView = getLView();
const interpolated = interpolation1(lView, prefix, v0, suffix);
if (interpolated !== NO_CHANGE) {
textBindingInternal(lView, getSelectedIndex(), interpolated);
}
return textInterpolate1;
}
function textBindingInternal(lView, index, value) {
const element = getNativeByIndex(index, lView);
// ^^ which DOM element should be updated
updateTextNode(lView[RENDERER], element, value);
// ^^ updates the DOM element with the value
}
function updateTextNode(renderer, rNode, value) {
renderer.setValue(rNode, value);
}
This understanding unlocks a useful pattern. Since pipes effectively cache their results for repeated inputs, we can build a wrapper utility that replicates this caching behavior for regular component methods. That allows us to sprinkle template expressions with function calls without a full-scale refactor into dedicated pipe classes — especially handy when the template contains many such calls.
Understanding Pure Pipes
A practical way to run function calls in templates with better performance is through a pure pipe. Here's how you define one:
@Pipe({
name: "pure",
standalone: true,
})
export class PurePipe implements PipeTransform {
/**
* @Inject(ChangeDetectorRef) prevents:
* NullInjectorError: No provider for EmbeddedViewRef!
*/
constructor(
@Inject(ChangeDetectorRef)
private readonly viewRef: EmbeddedViewRef<unknown>
) {}
/**
* @param fn - function executed in the template
* @param args - list of arguments for the function
* @returns - transformed function into a pipe behaviour
*/
transform<T extends (...args: any) => any>(
fn: T,
...args: [...Parameters<T>]
): ReturnType<T> {
return fn.apply(this.viewRef.context, args);
}
}
This pure pipe can then be used in templates like this:
@Component({
selector: "app-example-pipe",
template: `
<!-- rest of component -->
<!-- table body -->
<div *ngFor="let data of loadedAnime$ | async" class="...">
<!-- rest of table -->
<div>
{{ equasionAsyncFunction | pure : data | async }}
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [/* ... */, PurePipe],
})
export class ExamplePipeComponent {
/* ...... */
equasionAsyncFunction(anime: AnimeData): Observable<number> {
console.log(`%c [Async] Function call ${anime.title}`);
return this.apiService.hardMathEquasionAsync(anime);
}
/* ^^ function call in the template making an API call */
}
The GIF below illustrates what happens in practice. Even after selecting multiple items and invoking function calls directly in the template (equasionAsyncFunction), the internal logic — including the API call — does not rerun for every user interaction like typing in the input field. That behavior is typical when functions are used carelessly in templates.
Instead, the function behaves like a genuine Pipe. During each change detection cycle, Angular compares the previous input against the current one. When the inputs are identical — for example, the selected anime items haven't changed — the cached result is returned and the function body is skipped.
For projects already leveraging the ngxtension library, there's a CallPipe / ApplyPipe utility that closely mirrors the functionality of the pure pipe described above.
The Memoization Decorator Alternative
If you're after something more elegant, consider building a memoization decorator that can wrap any template-bound function instead of relying on the pure pipe.
What Is a Decorator?
At its core, a decorator is simply a function — technically a closure — that alters the behavior of another function. While the memoization decorator can be hand-rolled, here's a representative implementation:
import { first, tap, of } from "rxjs";
export function customMemoize() {
// cache already executed function calls in the template
const cacheLookup: { [key: string]: any } = {};
return (target: any, key: any, descriptor: any) => {
// store the original method behaviour
const originalMethod = descriptor.value;
// overwrite the original method
descriptor.value = function () {
// arguments can be an object -> stringify it
const keyString = JSON.stringify(arguments);
// already cached data
if (keyString in cacheLookup) {
console.log("reading from cache");
return cacheLookup[keyString];
}
// call the function with arguments
const calculation = originalMethod.apply(this, arguments);
// save data to cache
cacheLookup[keyString] = calculation;
// return calculated data
return calculation;
};
// return the overwritten function behaviour
return descriptor;
};
}
Important Design Points
The single most important detail is where the cacheLookup object lives. It must be declared outside the inner function so that cached results persist across invocations.
The inner function returns a wrapped version of the original method. For caching to work in templates, each computed result needs a unique key. Since arguments can be objects, the most straightforward strategy is to stringify them before storing or retrieving from the cache.
On the first call, the original method runs via originalMethod.apply(this, arguments) and the outcome gets recorded in the cache. For every subsequent invocation triggered by change detection — like user events — the decorator first inspects the cache. If a matching key exists, that stored value is returned immediately, bypassing the original function entirely.
@Component({
selector: "app-example-pipe",
template: `
<!-- rest of component -->
<!-- table body -->
<div *ngFor="let data of loadedAnime$ | async" class="...">
<!-- rest of table -->
<div>{{ equasionAsyncFunctionMemo(data) | async }}</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [/* ... */],
})
export class ExamplePipeComponent {
/* ...... */
@customMemoize()
equasionAsyncFunctionMemo(anime: AnimeData): Observable<number> {
console.log(`%c [Async] Function call ${anime.title}`);
return this.apiService.hardMathEquasionAsync(anime);
}
/* ^^ function call in the template */
}
The video below demonstrates the decorator in action:
Notice the repeated reading from cache logs. That's expected — the decorator runs on every change detection cycle triggered by user events. But because the underlying input hasn't changed, the actual template function never re-executes; the cached result is served instead.
Wrapping Up
This deep dive has uncovered how Angular pipes operate under the hood and why they offer such a performance advantage. The core takeaway: pipes cache their computed output and only recompute when their inputs actually change.
We also examined two practical stand-ins for pipe-like behavior: the Pure Pipe utility and the Memoization Decorator. Both enable safe function calls in templates by caching results, which boosts overall application responsiveness.
If you'd like to explore the full implementation firsthand, check out the github repo or the stackblitz example. Feel free to share your feedback or reach out on dev.to | LinkedIn.








