This post covers everything you need to know about pipes. We'll begin by constructing a pipe, then examine why they're useful. After that, we'll dig into Angular's internal implementation to see exactly how pipes function and when to apply them.

Additionally, this article walks through the answer to challenge #8 from Angular Challenges. This task was created for newcomers to get an initial feel for pipes. If you haven't attempted it, give it a go before returning to check my approach. (Feel free to submit a pull request, and I'll take a look)


The starting point for the challenge is this code:

@Component({
  standalone: true,
  imports: [NgFor],
  selector: 'app-root',
  template: `
    <div *ngFor="let person of persons; let index = index">
      {{ heavyComputation(person, index) }}
    </div>
  `,
})
export class AppComponent {
  persons = ['toto', 'jack'];

  heavyComputation(name: string, index: number) {
    // very heavy computation
    return `${name} - ${index}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we go through a basic loop that walks over an array containing names of people, and for every entry, the heavyComputation function gets invoked.

Within this illustration, the function stands in for a resource-intensive operation, though it might genuinely constitute an expensive task, like sorting an array.

Within an Angular application, template-level function calls are a frequent sight, since they offer the most direct route for transferring component data into the view. Still, you've likely encountered the advice: "Avoid function calls at all costs!" That guidance carries weight, because each change detection pass triggers a fresh evaluation of the function, and Angular may perform numerous such passes.

Be that as it may, invoking functions in templates isn't disallowed or inherently harmful. When the goal is simply to pull a single property off an object, as shown below, the overhead stays minimal, making the call perfectly reasonable.

getFirstname = (index: number) => person[index].firstname
Enter fullscreen mode Exit fullscreen mode

A function in a component method can become expensive to run with unmonitored changes, which could slow down the entire application.

Pipes provide a safer alternative for data transformation within a template. They come with memoization: when all inputs stay the same, the cached result is reused. When any input changes, the pipe runs its transform logic again.

Let’s update the template by introducing a pipe:

@Pipe({
  name: 'comput',
  standalone: true,
  pure: true // default value
})
export class ComputPipe implements PipeTransform {
  transform(name: string, index: number): string {
    // very heavy computation
    return `${name} - ${index}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

and our template become:

<div *ngFor="let person of persons; let index = index">
  {{ person | comput: index }}
</div>
Enter fullscreen mode Exit fullscreen mode

Note: 

  • The syntax of a pipe is as follow:
var | pipeName : arg1: arg2: arg3
Enter fullscreen mode Exit fullscreen mode
  • Pipes can be chained, allowing several to be applied in sequence.
var | pipe1 | pipe2
Enter fullscreen mode Exit fullscreen mode

The input var first undergoes transformation by pipe1, after which that result is processed by pipe2.

  • Pipes are pure by default. Under this setup, Angular caches the output from the initial computation and only reruns the pipe when at least one of its inputs changes.

Note: A pure pipe is required to rely on a pure function—one without any side effects. For consistent results, the same input must always yield the same output.

Alternatively, setting pure: false inside the pipe decorator produces an impure pipe. In that case, the pipe's function runs during every change detection cycle. Caution is advised here, as expensive functions can severely degrade performance. While an impure pipe behaves like a template-invoked function, it provides the flexibility to call the same function across different parts of your app.

The built-in AsyncPipe serves as an example of an impure pipe; it must re-evaluate the observable each cycle to ensure the view stays current.

  • A frequently overlooked detail is that the @Pipe decorator yields an injectable entity. You can therefore bring the pipe into any component, directive, or service and invoke its transform method directly.

Using the earlier pipe example, you might implement something similar to this:

@Injectable()
export class MyService {
  // we can inject the pipe
  computPipe = inject(ComputPipe);

  doSomething(){
    return this.computPipe('xxx', 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point, we have built a pipe and gained a basic grasp of how it works. Now, let's explore Angular's internal implementation to see what happens behind the scenes with a pipe.

To make sense of the upcoming code examples, we first need a quick look at how Angular handles template data. This calls for a simple introduction to the concepts of LView and TView.

In its internals, Angular takes all template information and organizes it into two structures: LView (Logical View) and TView (Template View).

  • TView is the compiled form of a component's or pipe's template, holding details about its structure and content. This includes metadata on directives, bindings, elements, styles, and more. During compilation, every component template gets transformed into a TView object. This object carries the static data required for efficient template rendering, and a single TView can be reused across multiple LView instances that rely on the same component.

  • LView serves as a runtime data structure tracking the current state of a component and its template. It stores the component's properties, methods, bindings, and the live template state. Angular's runtime uses this object and refreshes it whenever component properties or state undergo changes.

Looking back at our earlier example's perspective:

 

<div *ngFor="let person of persons; let index = index">
  {{ person | comput: index }}
</div>
Enter fullscreen mode Exit fullscreen mode

A TView instance is generated for both the AppComponent and the ComputPipe.

An LView instance is instantiated for the AppComponent, along with an individual LView for each element produced by the *ngFor loop.

LViewComputPipe_toto = LView{
  TViewComputPipe,
  // ...
  // state information
  // ...
}

LViewComputPipe_jack = LView{
  TViewComputPipe,
  // ...
  // state information
  // ...
}

LViewAppComponent = LView{
  TViewAppComponent,
  LViewComputPipe_toto,
  LViewComputPipe_jack,
  // ...
  // state information
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Here is a highly distilled representation of LView, yet it suffices for grasping the upcoming explanation. Let's walk through the logic to see what happens to pipes when a fresh change detection cycle begins.

Depending on how many arguments are passed, the function invoked for our pipe is ɵɵpipeBind[number of arg] — since comput receives two parameters, persons and index, that would be ɵɵpipeBind2 here. Consequently, every time change detection runs, the following function is triggered for each pipe we have.

function ɵɵpipeBind2(index, slotOffset, v1, v2) {
    const adjustedIndex = index + HEADER_OFFSET;
    const lView = getLView();
    const pipeInstance = load(lView, adjustedIndex);
    return isPure(lView, adjustedIndex) ?
        pureFunction2Internal(lView, getBindingRoot(), slotOffset, pipeInstance.transform, v1, v2, pipeInstance) :
        pipeInstance.transform(v1, v2);
}
Enter fullscreen mode Exit fullscreen mode
  • The trio index, HEADER_OFFSET, and slotOffset pinpoints where each argument's state, the transform outcome, and the pipe instance live inside the LView array.
  • v1 and v2 represent the two incoming values passed to the pipe.

Looking at the key entries in that LView, here is what stands out:

lView = {
// ...
24: ComputPipe {}
// ...
26: {__brand__: 'NO_CHANGE'}
27: {__brand__: 'NO_CHANGE'}
28: {__brand__: 'NO_CHANGE'}
// ...
}
Enter fullscreen mode Exit fullscreen mode
  • the pipe’s instance is saved in slot 24
  • the argument passed as arg1 is written into slot 26
  • the argument passed as arg2 is written into slot 27
  • slot 28 holds the memoized output of the transform method

Note: the LView presented here reflects its initial setup.

First off, we need to establish if the pipe is marked as pure by invoking the check below:

function isPure(lView: LView, index: number): boolean {
  return (<PipeDef<any>>lView[TVIEW].data[index]).pure;
}
Enter fullscreen mode Exit fullscreen mode

The value TVIEW = 1 serves as the index that holds the TView within the LView array. Because the TView holds all the static details, it gives us access to every piece of metadata associated with this pipe (illustrated in the following example)

{
  factory: ƒ ComputPipe_Factory(t),
  name: "comput",
  onDestroy: null,
  pure: true,
  standalone: true,
  type: class ComputPipe
}
Enter fullscreen mode Exit fullscreen mode

When a pipe is impure, the pipe.transform method is returned directly, and the whole function gets re-run.

In the common scenario of a pure pipe, though, execution goes through pureFunction2Internal.

export function pureFunction2Internal(...): any {
  const bindingIndex = bindingRoot + slotOffset;
  return bindingUpdated2(lView, bindingIndex, exp1, exp2) ?
      updateBinding(
          lView, bindingIndex + 2,
          thisArg ? pureFn.call(thisArg, exp1, exp2) : pureFn(exp1, exp2)) :
      getPureFunctionReturnValue(lView, bindingIndex + 2);
}
Enter fullscreen mode Exit fullscreen mode

Before the two freshly supplied arguments are accepted, the pureFunction2Internal method has to check them against the values already held within the LView. That verification is performed via the bindingUpdated2 call.

export function bindingUpdated2(lView: LView, bindingIndex: number, exp1: any, exp2: any): boolean {
  const different = bindingUpdated(lView, bindingIndex, exp1);
  return bindingUpdated(lView, bindingIndex + 1, exp2) || different;
}
Enter fullscreen mode Exit fullscreen mode
export function bindingUpdated(lView: LView, bindingIndex: number, value: any): boolean {
  const oldValue = lView[bindingIndex];

  if (Object.is(oldValue, value)) {
    return false;
  } else {
    lView[bindingIndex] = value;
    return true;
  }
Enter fullscreen mode Exit fullscreen mode

The bindingUpdated method evaluates whether the stored arg1 differs from the new one by relying on Object.is. A mismatch triggers a refresh of the LView with the latest argument value.

With Object.is, the behavior mirrors === mostly, but it treats -0 and 0 as unequal and considers Number.NaN equal to itself.

We perform the same bindingUpdated check on arg2, then combine the two outcomes by adding them together.

When every argument matches what we saw in the prior change detection pass, the cached result is returned via getPureFunctionReturnValue:

function getPureFunctionReturnValue(lView: LView, returnValueIndex: number) {
  const lastReturnValue = lView[returnValueIndex];
  return lastReturnValue === NO_CHANGE ? undefined : lastReturnValue;
}
Enter fullscreen mode Exit fullscreen mode

When that’s not the case, we invoke the transform method and store its output in the LView by calling updateBinding:

export function updateBinding(lView: LView, bindingIndex: number, value: any): any {
  return lView[bindingIndex] = value;
}
Enter fullscreen mode Exit fullscreen mode

Once the change detection pass has completed, the LView reflects the updated state data.

lView = {
// ...
24: ComputPipe {}
// ...
26: "toto"
27: 0
28: "toto - 0"
// ...
}
Enter fullscreen mode Exit fullscreen mode

As long as neither argument changes, the transform method of the pipe stays dormant, and the stored result from (spot 28) is handed back. The moment even a single argument shifts, the pipe springs back into action and runs again.

Heads up:

  • Whether the pipe takes just one argument or races past two, the underlying mechanism remains unchanged. Every argument gets inspected sequentially to decide if a fresh calculation is warranted.
  • After absorbing Enea Jaholli's insightful write-up (reference below), one might suggest wrapping the function with a memo helper inside the component rather than deploying a pipe. Yet the scenario depicted above slips through that tactic. A memo function shines only when calls use an identical argument list, since a lone instance exists. (Picture this: feed it 'toto', and 'toto' gets cached. Switch to 'titi', and because 'toto' differs from 'titi', it recalculates. On the next change detection pass, revisit 'toto', and despite the argument matching a prior input, the function executes anew because the cached entry is now 'titi'—and so the cycle continues.) Pipes, by contrast, possess their own dedicated LView, granting each one its own individual instance. This allows multiple identical pipes with distinct arguments to coexist within one template, all while tapping Angular's built-in caching logic.

With this, pipes should hold no mysteries left, and you can wield them productively across your project.

If this introduced you to a fresh Angular concept and you enjoyed it, feel free to connect with me on Twitter or Github.