Reusable Angular Components in the Real World: Inputs, Outputs, and Performance Without the Pain

Reusable Angular Components in the Real World: Inputs, Outputs, and Performance Without the Pain

Most Angular apps start simple: a page, a form, a list. Then requirements show up—sorting, filtering, loading states, empty states, “just one more button”—and suddenly your components are doing everything. This article walks through a practical pattern for building reusable Angular components that stay readable as features grow.

You’ll build a small “product list” UI using:

  • @Input() and @Output() for clean parent/child boundaries
  • Presentational vs container components (without over-engineering)
  • ChangeDetectionStrategy.OnPush + trackBy for smoother lists
  • A minimal, working unit test that proves the component behavior

1) The pattern: container owns data, presentational owns UI

When a component both fetches data and renders UI, it gets hard to reuse and hard to test. A simple rule helps:

  • Container component: fetches data, handles state, wires events
  • Presentational component: receives data via @Input(), emits events via @Output(), focuses on rendering

We’ll implement:

  • ProductsPageComponent (container)
  • ProductListComponent (presentational list)
  • ProductCardComponent (presentational item)

2) Define a small model and mock API (so you can run it anywhere)

Start with a model and a tiny service that returns an observable. You can swap this later for an HTTP call without rewriting your UI components.

// product.model.ts export type Product = { id: number; name: string; price: number; inStock: boolean; }; 
// products.service.ts import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { delay } from 'rxjs/operators'; import { Product } from './product.model'; @Injectable({ providedIn: 'root' }) export class ProductsService { private readonly products: Product[] = [ { id: 1, name: 'Keyboard', price: 39.99, inStock: true }, { id: 2, name: 'Mouse', price: 19.99, inStock: true }, { id: 3, name: 'Webcam', price: 79.99, inStock: false }, { id: 4, name: 'USB-C Hub', price: 49.99, inStock: true }, ]; list(): Observable<Product[]> { // Simulate network latency return of(this.products).pipe(delay(250)); } } 

3) Build a presentational card component

This component is intentionally “dumb”: it doesn’t fetch data, it doesn’t mutate global state, and it doesn’t know where the product came from.

// product-card.component.ts import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { Product } from './product.model'; @Component({ selector: 'app-product-card', templateUrl: './product-card.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductCardComponent { @Input({ required: true }) product!: Product; @Output() addToCart = new EventEmitter<number>(); onAdd(): void { this.addToCart.emit(this.product.id); } } 
<!-- product-card.component.html --> <div class="card"> <div class="title">{{ product.name }}</div> <div class="meta"> <span>{{ product.price | currency }}</span> <span *ngIf="!product.inStock" class="badge">Out of stock</span> </div> <button type="button" (click)="onAdd()" [disabled]="!product.inStock"> Add to cart </button> </div> 

Why OnPush? It tells Angular: “Only re-render this component when its inputs change or it emits an event.” For lists, this reduces unnecessary checks and keeps scrolling smoother.

4) Build a list component with trackBy to prevent UI jitter

Now build a list that renders many cards. The common mistake is forgetting trackBy. Without it, Angular may re-create DOM nodes when the array changes (like filtering/sorting), causing flicker or losing focus/scroll position in complex UIs.

// product-list.component.ts import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { Product } from './product.model'; @Component({ selector: 'app-product-list', templateUrl: './product-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductListComponent { @Input({ required: true }) products: Product[] = []; @Input() loading = false; @Output() addToCart = new EventEmitter<number>(); trackById(_index: number, item: Product): number { return item.id; } } 
<!-- product-list.component.html --> <ng-container *ngIf="loading; else loaded"> <p>Loading products...</p> </ng-container> <ng-template #loaded> <ng-container *ngIf="products.length > 0; else empty"> <div class="grid"> <app-product-card *ngFor="let p of products; trackBy: trackById" [product]="p" (addToCart)="addToCart.emit($event)"> </app-product-card> </div> </ng-container> <ng-template #empty> <p>No products match your filters.</p> </ng-template> </ng-template> 

Notice how the list doesn’t decide what “add to cart” means—it simply bubbles the event up.

5) The container wires data + UI together (and stays boring)

The container is where data fetching and UI state should live. It can filter/sort and pass the final array into the list component.

// products-page.component.ts import { Component, OnInit } from '@angular/core'; import { ProductsService } from './products.service'; import { Product } from './product.model'; @Component({ selector: 'app-products-page', templateUrl: './products-page.component.html' }) export class ProductsPageComponent implements OnInit { loading = true; private all: Product[] = []; visible: Product[] = []; query = ''; onlyInStock = false; constructor(private readonly productsService: ProductsService) {} ngOnInit(): void { this.productsService.list().subscribe({ next: (items) => { this.all = items; this.applyFilters(); this.loading = false; }, error: () => { this.all = []; this.visible = []; this.loading = false; } }); } onAddToCart(productId: number): void { // Replace with your cart service / state management console.log('Add to cart:', productId); } onQueryChange(value: string): void { this.query = value; this.applyFilters(); } onInStockToggle(value: boolean): void { this.onlyInStock = value; this.applyFilters(); } private applyFilters(): void { const q = this.query.trim().toLowerCase(); this.visible = this.all .filter(p => !this.onlyInStock || p.inStock) .filter(p => q === '' || p.name.toLowerCase().includes(q)) .slice() .sort((a, b) => a.name.localeCompare(b.name)); } } 
<!-- products-page.component.html --> <div class="toolbar"> <input type="search" placeholder="Search products..." [value]="query" (input)="onQueryChange(($any($event.target)).value)" /> <label> <input type="checkbox" [checked]="onlyInStock" (change)="onInStockToggle(($any($event.target)).checked)" /> In stock only </label> </div> <app-product-list [products]="visible" [loading]="loading" (addToCart)="onAddToCart($event)"> </app-product-list> 

This component is “boring” in a good way: it’s the single place where filtering rules live. The list and card components remain reusable in other pages.

6) A practical checklist for reusable components

  • Inputs are plain data: avoid passing services into presentational components.
  • Outputs are intent: prefer addToCart over something like clicked. Intent is more maintainable.
  • Keep state close: the component that owns the state should also own the transformations (filter/sort/pagination).
  • Use OnPush by default for presentational components; it’s an easy win for performance.
  • Always use trackBy when rendering lists. Your future self will thank you.

7) Unit test the behavior that matters (one small test goes far)

For junior/mid devs, testing can feel like a lot of ceremony. Start small: verify that a button click emits the expected ID. Here’s a focused test for ProductCardComponent.

// product-card.component.spec.ts import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ProductCardComponent } from './product-card.component'; describe('ProductCardComponent', () => { let fixture: ComponentFixture<ProductCardComponent>; let component: ProductCardComponent; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [ProductCardComponent] }).compileComponents(); fixture = TestBed.createComponent(ProductCardComponent); component = fixture.componentInstance; component.product = { id: 7, name: 'Test Item', price: 10, inStock: true }; fixture.detectChanges(); }); it('emits product id when Add to cart is clicked', () => { const spy = jasmine.createSpy('addToCartSpy'); component.addToCart.subscribe(spy); const btn = fixture.debugElement.query(By.css('button')); btn.triggerEventHandler('click', null); expect(spy).toHaveBeenCalledWith(7); }); }); 

This test is short, stable, and valuable: it locks in the contract between the card component and its parent.

8) Where to go next

Once this pattern clicks, you can evolve it without rewrites:

  • Add pagination in the container (slice the array and pass the page to the list).
  • Extract a reusable SearchInputComponent with @Output() valueChange.
  • If the app grows, move container state into a dedicated store/service—but keep presentational components unchanged.

The payoff is real: your UI stays modular, your features ship faster, and refactors become normal instead of terrifying.


Leave a Reply

Your email address will not be published. Required fields are marked *