Skip to content

Fix: Angular Pipe Not Working — Custom Pipe Not Transforming or async Pipe Not Rendering

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Angular pipe issues — declaring pipes in modules, standalone pipe imports, pure vs impure pipes, async pipe with observables, pipe chaining, and custom pipe debugging.

The Problem

A custom pipe has no effect in the template:

<!-- template -->
<p>{{ message | truncate:50 }}</p>
<!-- Output: same as {{ message }} — pipe does nothing -->

Or Angular throws InvalidPipeArgument:

Error: InvalidPipeArgument: 'Not a valid date' for pipe 'DatePipe'

Or the async pipe shows nothing despite an observable emitting values:

<div *ngIf="data$ | async as data">
  {{ data.name }}
</div>
<!-- Nothing shows even though data$ emits -->

Or a custom pipe doesn’t update when the input data changes:

<li *ngFor="let item of items | filterBy:searchTerm">
  <!-- List doesn't update when items array is mutated -->
</li>

Why This Happens

Angular pipes have strict declaration requirements and pure/impure behavior that causes silent failures:

  • Pipe not declared or imported — in module-based apps, a custom pipe must be declared in the NgModule where it’s used. In standalone components, it must be in the imports array. Missing this causes the template compiler to silently treat the pipe as unknown.
  • Pure pipe doesn’t detect mutations — by default, pipes are “pure” — they only re-run when the input reference changes. Mutating an array (push, splice) doesn’t change the reference, so a pure pipe sees no change. You need a new array reference or an impure pipe.
  • async pipe requires ChangeDetectionStrategy.OnPush setupasync pipe subscribes to observables and marks the view for check. Without proper change detection or when the observable completes without emitting, the view stays empty.
  • Wrong pipe transform signature — the transform() method must accept the value and optional arguments in the correct order. Wrong types or missing parameters cause InvalidPipeArgument.

The deeper reason silent failures happen is that the Angular template compiler treats unknown pipe names as opaque expressions. When the compiler cannot resolve truncate, it does not crash the build — it inserts a stub that returns the input value unchanged. The page renders, the pipe does nothing, and you spend an hour assuming the transform function is broken. The same is true when the pipe is declared in a module that the current component does not import: the template parses, runs, and returns the raw value. Always check the browser console for NG0303: Can't bind to '...' or NG8004: No pipe found with name '...' — those messages confirm whether the pipe is registered or silently ignored.

The pure vs. impure distinction is the other half of the story. Angular memoizes pure pipes by reference comparison on every change detection cycle. If you have items | filterBy:term, Angular only re-invokes transform() when the identity of items or term changes, not when the contents change. This is intentional: pipes run extremely often (every change detection tick, which can fire dozens of times per second during animations or user input), so re-running expensive transforms on stable inputs would destroy performance. Marking a pipe pure: false opts out of memoization and runs the transform on every cycle, which is rarely the right choice. The better fix is to give the pipe a new reference whenever the underlying data changes, using immutable update patterns.

How Other Tools Handle This

The reactivity model behind Angular pipes is unique among modern frameworks, and understanding the contrasts helps you reach for the right tool in each codebase.

Angular pure pipes vs Vue computed properties. Vue’s computed tracks dependencies automatically via its reactivity system. You write const filtered = computed(() => items.value.filter(i => i.name.includes(term.value))) and Vue invalidates the cache only when items or term changes. There is no concept of “pure” vs “impure” — every computed is effectively pure and tracks fine-grained reactive dependencies. If you mutate an array with push() on a reactive array, Vue still detects the change because the reactive proxy intercepts the mutation. Angular’s pipes, by contrast, only see reference changes.

Angular pipes vs React useMemo. useMemo(() => expensiveFilter(items, term), [items, term]) is closest to Angular’s pure pipe. Both rely on explicit dependency comparison, both memoize the result, and both miss mutations of the same reference. React does not have a built-in template pipe syntax — you call the function inline or extract it into a memoized derived value. The mental model is the same: change the reference (items = [...items, newItem]) to trigger recomputation.

Angular pipes vs Svelte derived stores. Svelte uses derived(items, $items => $items.filter(...)) to express derived state, and its compiler emits assignments that trigger reactivity. Svelte’s reactivity is statement-level (the $: reactive label) and store-level, not reference-based. Svelte 5 introduces $derived runes, which behave much like Vue computed.

Angular pipes vs Solid createMemo. Solid’s createMemo(() => filter(items(), term())) runs only when the signals it reads change, with the finest-grained tracking of any framework — Solid does not re-render components at all, only the specific reactive nodes that depend on changed signals. Angular’s OnPush change detection is the closest parallel, and Angular Signals (stable in 17+) are an explicit move toward this model.

Impure pipes vs OnPush interaction. This is where teams get caught. An impure pipe runs on every change detection cycle, which is fine in default change detection but disastrous if you combine it with OnPush: the OnPush parent does not trigger checks, so an impure pipe inside might never re-run. The cleanest fix is to express the derived value as a Signal or Observable, mark the component OnPush, and let the async pipe (which calls markForCheck() on emission) drive updates. Avoid impure pipes for filtering and sorting — use RxJS operators or Signals to derive the filtered collection upstream, then pass the result to the template as a normal binding.

Fix 1: Declare or Import the Pipe Correctly

Module-based Angular:

// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate',  // ← Must match the name used in template: | truncate
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit: number = 100, trail: string = '...'): string {
    if (!value) return '';
    return value.length > limit ? value.substring(0, limit) + trail : value;
  }
}
// app.module.ts — MUST declare the pipe here
@NgModule({
  declarations: [
    AppComponent,
    TruncatePipe,  // ← Pipe must be declared
  ],
  imports: [BrowserModule],
})
export class AppModule {}
// shared.module.ts — for pipes used across multiple modules
@NgModule({
  declarations: [TruncatePipe, FilterPipe, SortPipe],
  exports: [TruncatePipe, FilterPipe, SortPipe],  // ← Export to make available to importers
})
export class SharedModule {}

// Then import SharedModule in any module that needs the pipes
@NgModule({
  imports: [SharedModule],
})
export class FeatureModule {}

Standalone Angular (Angular 14+):

// truncate.pipe.ts — mark as standalone
@Pipe({
  name: 'truncate',
  standalone: true,  // ← Mark as standalone
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 100): string {
    return value.length > limit ? value.slice(0, limit) + '...' : value;
  }
}
// my-component.ts — import the pipe directly
@Component({
  selector: 'app-my',
  standalone: true,
  imports: [TruncatePipe, CommonModule],  // ← Import standalone pipe
  template: `<p>{{ text | truncate:50 }}</p>`,
})
export class MyComponent {}

Fix 2: Write the transform() Method Correctly

The first argument to transform() is always the value before the pipe, followed by any arguments passed after the colon:

// Template usage: {{ value | myPipe:arg1:arg2 }}

@Pipe({ name: 'myPipe', standalone: true })
export class MyPipe implements PipeTransform {
  // value: the input (what comes before |)
  // arg1, arg2: pipe arguments (what comes after :)
  transform(value: string, arg1: number, arg2 = 'default'): string {
    return `${value.slice(0, arg1)} ${arg2}`;
  }
}

Common pipe examples:

// Currency formatting
@Pipe({ name: 'customCurrency', standalone: true })
export class CustomCurrencyPipe implements PipeTransform {
  transform(value: number, currency = 'USD', decimals = 2): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency,
      minimumFractionDigits: decimals,
    }).format(value);
  }
}
// Usage: {{ price | customCurrency:'EUR':2 }}

// Time ago pipe
@Pipe({ name: 'timeAgo', standalone: true })
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date | string | number): string {
    const date = new Date(value);
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);

    if (seconds < 60) return `${seconds}s ago`;
    if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
    if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
    return `${Math.floor(seconds / 86400)}d ago`;
  }
}

Fix 3: Use Impure Pipes for Mutable Data

Pure pipes (the default) don’t update when array or object contents change:

// PROBLEM: Pure pipe doesn't detect array mutation
@Pipe({ name: 'filterBy' })
export class FilterByPipe implements PipeTransform {
  transform(items: any[], term: string): any[] {
    return items.filter(item => item.name.includes(term));
  }
}

// In component:
addItem() {
  this.items.push({ name: 'new item' });  // Mutates array — pure pipe doesn't re-run
}

Fix option 1 — use pure: false (impure pipe):

@Pipe({
  name: 'filterBy',
  pure: false,  // Runs on every change detection cycle
  standalone: true,
})
export class FilterByPipe implements PipeTransform {
  transform(items: any[], term: string): any[] {
    if (!term) return items;
    return items.filter(item =>
      item.name.toLowerCase().includes(term.toLowerCase())
    );
  }
}

Warning: Impure pipes run on every change detection cycle. For expensive operations, cache results or use memoization to avoid performance issues.

Fix option 2 — return a new array reference (better for performance):

// Instead of mutating:
this.items.push(newItem);  // Mutation — pure pipe misses this

// Create a new array reference:
this.items = [...this.items, newItem];  // New reference — pure pipe re-runs

Fix 4: Use the async Pipe Correctly

The async pipe subscribes to Observable or Promise and unsubscribes automatically:

// Component
@Component({
  selector: 'app-users',
  standalone: true,
  imports: [CommonModule, AsyncPipe],
  template: `
    <!-- Handle loading/error states -->
    <ng-container *ngIf="users$ | async as users; else loading">
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ng-container>

    <ng-template #loading>Loading...</ng-template>
  `,
})
export class UsersComponent {
  users$ = this.userService.getUsers();

  constructor(private userService: UserService) {}
}

Common async pipe patterns:

<!-- Pattern 1: ngIf with as -->
<div *ngIf="data$ | async as data">
  {{ data.name }}
</div>

<!-- Pattern 2: Separate loading/error/data states with RxJS -->
<!-- In component: combine state into a single observable -->

<!-- Pattern 3: ngFor with async -->
<ul>
  <li *ngFor="let item of items$ | async">{{ item }}</li>
</ul>

<!-- Pattern 4: With OnPush change detection (recommended) -->
// With OnPush — more efficient, async pipe triggers change detection correctly
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div *ngIf="data$ | async as data">{{ data }}</div>`,
})
export class EfficientComponent {
  data$ = this.service.getData();
}

Why async pipe shows nothing:

// PROBLEM 1 — Observable never emits
data$ = new Subject<string>();  // Subject with no .next() call → async shows nothing

// PROBLEM 2 — Observable errors immediately
data$ = this.http.get('/api').pipe(
  // No catchError — any error kills the stream, async shows nothing
);

// FIX — handle errors
data$ = this.http.get('/api/users').pipe(
  catchError(err => {
    console.error(err);
    return of([]);  // Emit empty array on error so template shows something
  })
);

Fix 5: Chain Pipes and Handle Null Values

Pipes can be chained. Handle null/undefined to avoid errors:

<!-- Chaining pipes -->
{{ text | truncate:100 | uppercase }}
{{ date | date:'mediumDate' | uppercase }}
{{ price | currency:'USD' }}

<!-- Safe navigation operator + pipe -->
{{ user?.createdAt | date:'short' }}

<!-- ngIf to guard against null -->
<span *ngIf="user?.name">{{ user.name | titlecase }}</span>

Handle null in custom pipes:

@Pipe({ name: 'safeHtml', standalone: true })
export class SafeHtmlPipe implements PipeTransform {
  constructor(private sanitizer: DomSanitizer) {}

  transform(value: string | null | undefined): SafeHtml {
    if (!value) return '';  // Guard against null/undefined
    return this.sanitizer.bypassSecurityTrustHtml(value);
  }
}

Fix 6: Test Custom Pipes

Pipe unit tests are straightforward:

// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
  let pipe: TruncatePipe;

  beforeEach(() => {
    pipe = new TruncatePipe();
  });

  it('returns the full string when shorter than limit', () => {
    expect(pipe.transform('Hello', 10)).toBe('Hello');
  });

  it('truncates long strings', () => {
    expect(pipe.transform('Hello World', 5)).toBe('Hello...');
  });

  it('handles null input', () => {
    expect(pipe.transform(null as any, 10)).toBe('');
  });

  it('uses custom trail character', () => {
    expect(pipe.transform('Hello World', 5, ' →')).toBe('Hello →');
  });
});

// Testing in a component
describe('MyComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [MyComponent, TruncatePipe],  // Import standalone pipe
    });
  });

  it('truncates long text in the template', () => {
    const fixture = TestBed.createComponent(MyComponent);
    fixture.componentInstance.text = 'A very long string that should be truncated';
    fixture.detectChanges();
    const el = fixture.nativeElement.querySelector('p');
    expect(el.textContent.length).toBeLessThan(20);
  });
});

Still Not Working?

Pipe name collision — if two pipes have the same name string, one overrides the other. Angular doesn’t warn about this. Check all pipe declarations for duplicate names.

date pipe requires a valid date{{ value | date }} throws InvalidPipeArgument if value is not a Date, a number (timestamp), or an ISO 8601 string. Convert the value before piping: {{ value | date }} where value = new Date(timestamp).

Pipe in a lazy-loaded module — if the component is in a lazy-loaded module, the pipe must be declared in that module (not just the root module). In standalone mode, import the pipe directly in the component — no module boundary issues.

Signal-based components ignore pure pipe memoization — when a component reads a Signal and passes its value through a pipe, the pipe re-runs every time the Signal changes, but pure pipe memoization still applies. If you wrap a Signal in toObservable() and then pipe through async, the cache key is the emitted reference, not the Signal itself. Convert at the boundary: use computed() for derived Signals, and reserve pipes for simple formatting.

Pipes run during server-side rendering — under Angular SSR, pipes execute on the server with no browser APIs available. A pipe that reads window, document, or localStorage throws at build time. Guard with isPlatformBrowser(this.platformId) injected via PLATFORM_ID, or move the logic into the component class where SSR-safe defaults are easier to set.

*Async pipe inside ngIf creates a new subscription on every render<div *ngIf="data$ | async as data"> is fine, but <div *ngIf="(data$ | async).length > 0"> re-evaluates the pipe twice per render (once for the condition, once for the value), creating two subscriptions. Use the as alias to share the result, or pre-compute in the component.

For related Angular issues, see Fix: Angular Change Detection Not Working, Fix: Angular Signals Not Updating, Fix: Angular RxJS Memory Leak, and Fix: Angular Form Validation Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles