Homeβ€Ί JavaScriptβ€Ί Angular Components, Modules & Services: Production Patterns That Scale

Angular Components, Modules & Services: Production Patterns That Scale

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Advanced JS β†’ Topic 22 of 22
Angular components, modules, and services explained with production patterns, real trade-offs, and the gotchas that burn teams at scale.
βš™οΈ Intermediate β€” basic JavaScript knowledge assumed
In this tutorial, you'll learn:
  • A component that calls HttpClient directly is a design failure, not a shortcut. The component owns the UI; a service owns the data fetching. Mix them and you can't unit test, can't reuse, and can't reason about state.
  • RouterModule.forRoot() in a feature module is one of the most silent, destructive bugs in Angular. It creates a second Router instance and breaks navigation in ways that don't throw errors β€” they just cause unpredictable behaviour under load. Always forChild() in feature modules.
  • Reach for providedIn: 'root' by default for any stateless or globally-shared service. Switch to component-level providers only when you need isolated, component-scoped state that must be destroyed with its host β€” like a draft form state or a per-dialog polling stream.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Think of an Angular app like a large restaurant chain. Each branch (component) handles its own dining room and customer interactions independently. The franchise rulebook (module) groups related branches together and defines what equipment they share. The central kitchen supplier (service) provides ingredients to every branch without each one needing its own farm. When one branch runs out of a sauce, it calls the supplier β€” it doesn't try to grow tomatoes itself. That's Angular's architecture in one lunch conversation.

A team I consulted for spent three weeks hunting a memory leak that was crashing their Angular dashboard every four hours in production. Root cause: they were instantiating a new HttpClient inside a component instead of injecting a shared service, spinning up a fresh connection pool on every component mount and never releasing it. Four hours was exactly how long it took to exhaust the browser's connection limit. The fix was twelve characters. The three weeks were pure ignorance of how Angular's dependency injection actually works.

Angular's three-pillar architecture β€” components, modules, and services β€” isn't ceremony for ceremony's sake. It's a hard answer to a real problem: how do you build a 200-screen enterprise app with a team of 15 developers without it collapsing into a dependency nightmare? Without this structure you get components doing HTTP calls, state management, DOM manipulation, and business logic all in one file. I've seen it. It looks like someone let an intern rewrite jQuery in TypeScript. The separation isn't optional architecture philosophy β€” it's load-bearing.

After working through this, you'll be able to wire up a component that consumes a singleton service, register it correctly in a feature module, know exactly why providedIn: 'root' exists and when NOT to use it, and spot the three most common architectural mistakes before they hit your code review. You'll also understand why lazy-loaded modules break service singletons if you don't know what you're doing β€” which is the interview question that separates people who've actually shipped Angular from people who've done the Tour of Heroes.

Components: The Unit of UI β€” and Why They Must Stay Dumb

A component's only job is to display data and capture user intent. That's it. The moment a component starts making HTTP calls directly, manipulating global state, or containing business logic, you've built a monolith inside a framework that was designed to stop you from doing exactly that.

Before Angular, teams building large SPAs with frameworks like Backbone or early AngularJS (v1) routinely ended up with controllers that were thousands of lines long. Testing was impossible without spinning up the entire app. Reuse was a joke. A component-based architecture forces a hard boundary: the component owns the template and the interaction logic wired to that template, nothing else.

Every Angular component is defined by three things: a TypeScript class (the brain), a template (the face), and styles (the skin). The @Component decorator is what tells Angular's compiler to treat this class as a UI unit. The selector is how you stamp it into other templates. Change detection is how Angular knows when to re-render β€” and this is where most intermediate developers are still fuzzy. Angular's default change detection strategy (CheckAlways) rerenders the component on every event cycle. Switch to OnPush for any component receiving data via @Input and you cut unnecessary renders dramatically. On a dashboard with 80 components, this is the difference between 60fps and a janky mess.

OrderSummaryComponent.ts Β· TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// io.thecodeforge β€” JavaScript tutorial

import {
  Component,
  Input,
  Output,
  EventEmitter,
  ChangeDetectionStrategy,
  OnInit
} from '@angular/core';
import { OrderService } from '../services/order.service';
import { Order, OrderStatus } from '../models/order.model';

// OnPush tells Angular: only re-render this component when its @Input
// references change, or when an event originates inside it.
// For a component that only renders passed-in data, this is always correct.
@Component({
  selector: 'app-order-summary',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="order-card" [class.order-card--urgent]="isUrgent">
      <h3>Order #{{ order.id }}</h3>
      <p>{{ order.customerName }} β€” {{ order.totalAmount | currency }}</p>
      <p class="status" [attr.data-status]="order.status">
        {{ order.status }}
      </p>
      <!-- Emit intent upward β€” never mutate the input directly.
           Mutating @Input directly breaks OnPush and causes
           change detection bugs that are extremely hard to trace. -->
      <button
        *ngIf="order.status === OrderStatus.PENDING"
        (click)="confirmOrder()"
      >
        Confirm
      </button>
    </div>
  `
})
export class OrderSummaryComponent implements OnInit {
  // Readonly alias so the template can reference the enum without
  // importing it separately in the template context.
  readonly OrderStatus = OrderStatus;

  // @Input signals that parent components control this data.
  // This component is a pure renderer β€” it doesn't fetch anything.
  @Input() order!: Order;

  // @Output carries user intent back to the parent.
  // The parent decides what confirmation actually means β€”
  // maybe it calls an API, maybe it updates local state first.
  @Output() orderConfirmed = new EventEmitter<string>();

  // isUrgent is derived state, computed once on init from the input.
  // Not fetched. Not stored in a service. Pure derivation.
  isUrgent = false;

  constructor(private readonly orderService: OrderService) {}

  ngOnInit(): void {
    // Compute derived display logic from the input data.
    // OrderService here provides formatting/validation logic,
    // not a new HTTP call β€” the parent already fetched the order.
    this.isUrgent = this.orderService.isOrderUrgent(this.order);
  }

  confirmOrder(): void {
    // Component emits the intent. Parent handles the side effect.
    // This keeps the component unit-testable with zero HTTP mocking.
    this.orderConfirmed.emit(this.order.id);
  }
}
β–Ά Output
Component renders order card with customer name, total, and status.
Clicking 'Confirm' emits orderConfirmed event with the order ID to the parent.
No HTTP call is made inside this component.
With OnPush, Angular skips re-rendering this component unless the `order` reference itself changes.
⚠️
Production Trap: Mutating @Input ObjectsIf you mutate a property inside an @Input object (e.g., order.status = 'CONFIRMED') instead of passing a new object reference, OnPush will never detect the change and your UI will silently show stale data. Always treat @Input objects as immutable. Return a new object from the parent: { ...order, status: 'CONFIRMED' }. This is the single most common OnPush bug I've seen in code reviews.

Modules: The Dependency Manifest Nobody Reads Until Production Breaks

NgModules are the most misunderstood part of Angular. Most developers treat them as a bureaucratic registration step β€” you paste your component name into declarations and move on. That's wrong, and it will cost you either bundle size or broken singleton services when you eventually add lazy loading.

The real job of a module is to define a compilation context: which components, directives, and pipes can see each other, and which services are scoped to that compilation boundary. The declarations array is not a list of components you own β€” it's a list of components that belong to this module's template compiler. You can't declare a component in two modules. Angular will throw: 'Component X is declared in more than one NgModule.' That error is Angular enforcing a hard architectural rule, not being difficult.

The imports array is where teams get lazy. Don't import SharedModule into every feature module by default if SharedModule imports FormsModule, ReactiveFormsModule, and 30 components. You're pulling all of that into every lazy-loaded chunk's initial parse cost. Be surgical. Import only what the module actually uses. I've seen lazy-loaded route bundles balloon from 40KB to 340KB because someone added a convenience 'import everything' SharedModule to a feature module that used two components from it.

OrdersFeatureModule.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// io.thecodeforge β€” JavaScript tutorial

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { ReactiveFormsModule } from '@angular/forms';

import { OrdersPageComponent } from './orders-page/orders-page.component';
import { OrderSummaryComponent } from './order-summary/order-summary.component';
import { OrderFilterComponent } from './order-filter/order-filter.component';
import { OrderService } from './services/order.service';

// Define routes at the feature level, not in the root router.
// This is the contract the AppRoutingModule imports lazily.
const ORDERS_ROUTES: Routes = [
  {
    path: '',
    component: OrdersPageComponent
  },
  {
    // Lazy-load order detail only when the user navigates to it.
    // This route never appears in the main bundle.
    path: ':orderId/detail',
    loadChildren: () =>
      import('./order-detail/order-detail.module').then(
        (m) => m.OrderDetailModule
      )
  }
];

@NgModule({
  declarations: [
    // These components exist ONLY in this module's template compiler.
    // They cannot be used in other modules unless exported.
    OrdersPageComponent,
    OrderSummaryComponent,
    OrderFilterComponent
  ],
  imports: [
    // CommonModule provides *ngIf, *ngFor, async pipe.
    // Don't import BrowserModule here β€” that's root-only.
    // BrowserModule in a feature module throws a runtime error
    // about duplicate providers for BrowserModule.
    CommonModule,

    // Only import ReactiveFormsModule if this module actually
    // uses reactive forms. Every import adds to the chunk size.
    ReactiveFormsModule,

    RouterModule.forChild(ORDERS_ROUTES)
    // forChild, NOT forRoot. forRoot registers the router service.
    // Calling forRoot in a lazy module creates a second Router
    // instance, which silently breaks navigation β€” one of the
    // hardest bugs to diagnose in an Angular app.
  ],
  // Export only what other modules need to consume.
  // OrderFilterComponent is internal β€” don't export it.
  exports: [OrderSummaryComponent],
  providers: [
    // Providing OrderService here scopes it to this lazy module.
    // A new instance is created when the module loads.
    // If you need a single app-wide instance, use providedIn: 'root'
    // in the service itself instead of listing it here.
    // Listing it here AND using providedIn: 'root' means the
    // module-level provider wins for components in this module β€”
    // a second instance gets created and your singleton is broken.
    OrderService
  ]
})
export class OrdersModule {}
β–Ά Output
Feature module compiled successfully.
OrdersPageComponent and OrderFilterComponent are internal to this module.
OrderSummaryComponent is exported and can be used by any module that imports OrdersModule.
Navigating to /orders/:orderId/detail triggers a separate lazy-loaded chunk β€” not included in the orders chunk.
⚠️
Never Do This: RouterModule.forRoot() Inside a Feature ModuleCalling RouterModule.forRoot(routes) inside a lazy-loaded feature module instantiates a second Router service. The symptom: navigation events fire twice, router guards execute unpredictably, and the URL updates but the view doesn't change. Angular won't throw an error β€” it'll just silently misbehave under load. Always use RouterModule.forChild() in feature modules.

Services and Dependency Injection: Where Your Architecture Actually Lives

Services are where your business logic lives. Not in components, not in utility files, not in a god-object store β€” in injectable services with a single, clear responsibility. The reason Angular's DI system exists is that it solves a problem that's invisible when your app is small and catastrophic when it isn't: how do you ensure that the same instance of a stateful object is shared across dozens of components without those components knowing about each other?

providedIn: 'root' is the correct default for most services. It registers the service with the root injector, making it a true singleton for the app's lifetime. Angular's tree-shaking will also remove it from the bundle if nothing actually injects it β€” something the old providers: [] module pattern doesn't give you. The one time you don't want providedIn: 'root' is when you need component-level or lazy-module-level isolation: a form state service that should reset when a component is destroyed, or a polling service that should stop when a feature module is unloaded.

The DI system is hierarchical. Root injector at the top, module injectors in the middle, component injectors at the bottom. When a component asks for a dependency, Angular walks up the injector tree until it finds a provider. If you provide a service at the component level (via the component's providers array), each instance of that component gets its own service instance. I've used this deliberately for a shopping cart draft service that needed to be isolated per product modal β€” each modal got its own state, destroyed with the modal.

OrderPollingService.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// io.thecodeforge β€” JavaScript tutorial

import { Injectable, OnDestroy } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
  BehaviorSubject,
  Observable,
  Subject,
  timer
} from 'rxjs';
import {
  switchMap,
  catchError,
  takeUntil,
  tap,
  retry,
  distinctUntilChanged
} from 'rxjs/operators';
import { Order, OrderStatus } from '../models/order.model';
import { environment } from '../../environments/environment';

export interface OrderPollState {
  orders: Order[];
  lastUpdated: Date | null;
  error: string | null;
  isLoading: boolean;
}

const INITIAL_STATE: OrderPollState = {
  orders: [],
  lastUpdated: null,
  error: null,
  isLoading: false
};

// providedIn: 'root' means ONE instance for the entire app lifetime.
// Angular's DI creates it lazily on first injection and tree-shakes it
// if nothing injects it β€” unlike listing it in a module's providers array.
@Injectable({ providedIn: 'root' })
export class OrderPollingService implements OnDestroy {
  // BehaviorSubject holds the current state and replays it to
  // late subscribers β€” components that mount after the first poll
  // get the current value immediately, not a blank slate.
  private readonly stateSubject = new BehaviorSubject<OrderPollState>(INITIAL_STATE);

  // Exposed as Observable so consumers can't call .next() from outside.
  // This is the single source of truth for order state in the app.
  readonly state$: Observable<OrderPollState> = this.stateSubject.asObservable();

  // destroy$ is the standard Angular pattern for unsubscribing.
  // When takeUntil(this.destroy$) fires, all polling subscriptions clean up.
  private readonly destroy$ = new Subject<void>();

  private readonly POLL_INTERVAL_MS = 15_000;
  private readonly MAX_RETRIES = 3;
  private isPolling = false;

  constructor(private readonly http: HttpClient) {}

  startPolling(statusFilter: OrderStatus): void {
    // Guard against starting multiple polling streams.
    // Without this, every call to startPolling creates a new interval
    // and you end up with N parallel HTTP requests firing simultaneously.
    if (this.isPolling) return;
    this.isPolling = true;

    timer(0, this.POLL_INTERVAL_MS)
      .pipe(
        // switchMap cancels the previous HTTP request if the timer fires
        // before the last request completed. Without switchMap here,
        // slow responses cause overlapping requests and race conditions.
        switchMap(() => {
          this.patchState({ isLoading: true, error: null });
          return this.http
            .get<Order[]>(
              `${environment.apiBase}/orders?status=${statusFilter}`
            )
            .pipe(
              // Retry up to 3 times on failure before surfacing the error.
              // Use retry({ count, delay }) in RxJS 7+ for exponential backoff.
              retry(this.MAX_RETRIES),
              catchError((err: HttpErrorResponse) => {
                this.patchState({
                  isLoading: false,
                  error: `Order fetch failed: ${err.status} ${err.statusText}`
                });
                // Return current orders so the UI doesn't blank out on error.
                return [this.stateSubject.getValue().orders];
              })
            );
        }),
        tap((orders: Order[]) => {
          this.patchState({
            orders,
            isLoading: false,
            lastUpdated: new Date(),
            error: null
          });
        }),
        // Only emit downstream if the order list actually changed.
        // Prevents unnecessary re-renders when nothing has changed.
        distinctUntilChanged(
          (prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)
        ),
        // When destroy$ fires (service destroyed), the interval stops
        // and the HTTP request is cancelled. No lingering subscriptions.
        takeUntil(this.destroy$)
      )
      .subscribe();
  }

  stopPolling(): void {
    this.isPolling = false;
    this.destroy$.next();
  }

  // Shallow merge patch β€” doesn't replace the whole state object,
  // so partial updates don't wipe fields callers aren't touching.
  private patchState(partial: Partial<OrderPollState>): void {
    this.stateSubject.next({
      ...this.stateSubject.getValue(),
      ...partial
    });
  }

  ngOnDestroy(): void {
    // Root-provided services live as long as the app, so ngOnDestroy
    // fires only on app teardown. But component-scoped services use
    // this to clean up when their host component is destroyed.
    this.destroy$.next();
    this.destroy$.complete();
  }
}
β–Ά Output
OrderPollingService instantiated once at app root.
startPolling('PENDING') begins a 15s interval immediately (timer starts at 0).
First poll: GET /api/orders?status=PENDING β€” state updates with orders array and lastUpdated timestamp.
On HTTP 503: retries 3 times, then patches state with error message, preserves existing orders in the UI.
Calling stopPolling() cancels the active interval and any in-flight HTTP request.
stopPolling() called: RxJS takeUntil fires, interval destroyed, no memory leak.
⚠️
Production Trap: Service Provided in Both Root and Lazy ModuleIf a service has providedIn: 'root' AND is listed in a lazy-loaded module's providers array, Angular creates two instances: one in the root injector, one in the lazy module's injector. Components in that module get the lazy instance; everything else gets the root instance. State changes in one instance are invisible to the other. Symptom: service state updates on one page but not another, seemingly at random. Fix: pick one. Use providedIn: 'root' and remove it from every module's providers array, unless you explicitly want isolated instances.

Wiring It All Together: The Feature Module Pattern That Survives at Scale

The real test of understanding Angular's architecture isn't knowing what each piece is β€” it's knowing how they interact at the boundaries. Specifically: how data flows from a service into a component through a module, and where each responsibility lives.

The pattern that holds up at scale is the smart/dumb component split. One container component (smart) that injects services, subscribes to observables, and passes data down via @Input. Many presentational components (dumb) that receive @Input, emit @Output, and know nothing about services. This keeps your template hierarchy clean, your OnPush change detection effective, and your tests fast β€” you can unit test presentational components with zero mocking.

The async pipe is your friend here. Don't subscribe in ngOnInit and assign to a local variable β€” that's manual subscription management, and you WILL eventually forget to unsubscribe somewhere. The async pipe subscribes, passes the value to the template, and unsubscribes automatically when the component is destroyed. It also triggers change detection correctly with OnPush, which manual subscriptions don't do by default. I have personally seen a dashboard that had 40 manual subscriptions in a single component with zero unsubscribes. Every navigation to that page leaked a new set of subscriptions. After ten minutes of use, the app had hundreds of active HTTP polling intervals.

OrdersDashboardContainer.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
// io.thecodeforge β€” JavaScript tutorial

import {
  Component,
  OnInit,
  OnDestroy,
  ChangeDetectionStrategy
} from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { OrderPollingService, OrderPollState } from '../services/order-polling.service';
import { OrderService } from '../services/order.service';
import { Order, OrderStatus } from '../models/order.model';

// This is the SMART (container) component.
// It knows about services. It does NOT own template complexity.
// Its template delegates all rendering to dumb components.
@Component({
  selector: 'app-orders-dashboard',
  // OnPush works here because all data comes through the async pipe β€”
  // Angular's async pipe marks the view dirty when the observable emits,
  // even under OnPush, without you doing anything extra.
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ng-container *ngIf="orderState$ | async as state">
      <!-- Pass the loading flag down β€” no service knowledge in child -->
      <app-loading-bar *ngIf="state.isLoading"></app-loading-bar>

      <app-order-error-banner
        *ngIf="state.error"
        [message]="state.error"
      ></app-order-error-banner>

      <p *ngIf="state.lastUpdated" class="refresh-time">
        Last updated: {{ state.lastUpdated | date:'HH:mm:ss' }}
      </p>

      <!-- Loop over orders, pass each to the dumb summary component.
           The parent handles confirmOrder β€” the child just emits intent. -->
      <app-order-summary
        *ngFor="let order of state.orders; trackBy: trackByOrderId"
        [order]="order"
        (orderConfirmed)="handleOrderConfirmed($event)"
      ></app-order-summary>

      <p *ngIf="!state.isLoading && state.orders.length === 0">
        No pending orders.
      </p>
    </ng-container>
  `
})
export class OrdersDashboardContainerComponent implements OnInit, OnDestroy {
  // Expose the entire state stream to the template.
  // The async pipe handles subscribe/unsubscribe.
  // No local order[] variable. No ngOnDestroy subscription cleanup needed here.
  orderState$!: Observable<OrderPollState>;

  constructor(
    private readonly pollingService: OrderPollingService,
    private readonly orderService: OrderService
  ) {}

  ngOnInit(): void {
    this.orderState$ = this.pollingService.state$;
    // Start polling only when this container is alive.
    this.pollingService.startPolling(OrderStatus.PENDING);
  }

  // trackBy prevents Angular from destroying and recreating DOM nodes
  // on every poll cycle. Without this, every 15s every order card
  // unmounts and remounts β€” animations reset, focus is lost.
  trackByOrderId(_index: number, order: Order): string {
    return order.id;
  }

  handleOrderConfirmed(orderId: string): void {
    // The container owns the side effect. The child just emitted intent.
    this.orderService.confirmOrder(orderId).subscribe({
      next: () => {
        // On success, the polling interval will pick up the status change.
        // No manual state mutation needed β€” the server is the source of truth.
        console.info(`Order ${orderId} confirmed successfully.`);
      },
      error: (err) => {
        console.error(`Failed to confirm order ${orderId}:`, err);
        // In production: dispatch to an error notification service here.
      }
    });
  }

  ngOnDestroy(): void {
    // Stop polling when the user navigates away from the dashboard.
    // Without this, polling continues in the background forever.
    this.pollingService.stopPolling();
  }
}
β–Ά Output
OrdersDashboardContainerComponent mounts and startPolling(PENDING) fires immediately.
async pipe subscribes to orderState$ β€” no manual subscription in component code.
First poll completes: template renders order cards with trackBy preventing full DOM rerender.
Every 15s: state updates, only changed order cards re-render due to OnPush + trackBy.
User clicks Confirm on order #4821: handleOrderConfirmed('4821') fires, PATCH /orders/4821 sent.
User navigates away: ngOnDestroy fires, stopPolling() cancels interval, async pipe unsubscribes, zero memory leak.
⚠️
Senior Shortcut: Always Use trackBy With *ngFor on Polled DataWithout trackBy, Angular's ngFor re-creates the entire list of DOM nodes on every change detection cycle. For a polled list that refreshes every 15 seconds, every visible order card unmounts and remounts. This resets scroll position, kills CSS animations mid-frame, and loses any focused input inside the list. trackBy: trackByOrderId tells Angular to match existing nodes by ID and only touch what actually changed. Add it by default on any ngFor iterating data from an observable or HTTP response.
AspectComponent-Level ProviderRoot-Level Provider (providedIn: 'root')
Instance countOne per component instanceOne for entire app lifetime
LifetimeDestroyed with the componentLives until app teardown
Use caseIsolated form state, per-dialog stateHTTP services, auth, shared data
Tree-shakingNo β€” always included if component is usedYes β€” removed if nothing injects it
Lazy module isolationScoped to that component subtree onlyShared across all lazy modules
State leakage riskNone β€” destroyed with componentHigh if state is not explicitly reset
Test isolationEasy β€” each test gets its own instanceRequires TestBed override to isolate
Bundle impactIncluded in the module that declares the componentIncluded in main bundle only if injected

🎯 Key Takeaways

  • A component that calls HttpClient directly is a design failure, not a shortcut. The component owns the UI; a service owns the data fetching. Mix them and you can't unit test, can't reuse, and can't reason about state.
  • RouterModule.forRoot() in a feature module is one of the most silent, destructive bugs in Angular. It creates a second Router instance and breaks navigation in ways that don't throw errors β€” they just cause unpredictable behaviour under load. Always forChild() in feature modules.
  • Reach for providedIn: 'root' by default for any stateless or globally-shared service. Switch to component-level providers only when you need isolated, component-scoped state that must be destroyed with its host β€” like a draft form state or a per-dialog polling stream.
  • The async pipe isn't just a convenience β€” it's architectural correctness. It guarantees unsubscription on component destroy, triggers OnPush change detection correctly, and removes an entire class of memory leaks. If you're still assigning observable values to local variables in ngOnInit, you're writing Angular like it's 2016.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Subscribing to an Observable in ngOnInit and assigning to a local variable without unsubscribing β€” Symptom: 'ExpressionChangedAfterItHasBeenCheckedError' on navigation, ghost HTTP requests in the Network tab after the component is destroyed, memory growing with each navigation β€” Fix: use the async pipe in the template, or store the Subscription and call subscription.unsubscribe() in ngOnDestroy, or use takeUntil(this.destroy$) with a Subject.
  • βœ•Mistake 2: Importing BrowserModule in a feature module instead of CommonModule β€” Exact error: 'BrowserModule has already been loaded. If you need access to common directives such as NgIf and NgFor from a lazy loaded module, import CommonModule instead.' β€” Fix: BrowserModule is root-only. Use CommonModule in every feature and shared module. BrowserModule re-exports CommonModule, so you only need it once at AppModule level.
  • βœ•Mistake 3: Calling RouterModule.forRoot() in a lazy-loaded feature module instead of forRoot() in AppModule and forChild() everywhere else β€” Symptom: navigating to a child route works once, then subsequent navigations silently fail to change the view, or NavigationEnd fires but the component doesn't mount β€” Fix: use RouterModule.forChild(routes) in every module except AppModule. forRoot() registers the singleton Router service; forChild() only adds routes to the existing router.
  • βœ•Mistake 4: Providing a service in both providedIn: 'root' AND in a lazy module's providers array β€” Symptom: state updates made in one part of the app are invisible to components in the lazy module (two separate instances exist) β€” Fix: remove the service from the module's providers array entirely and rely solely on providedIn: 'root', or remove providedIn: 'root' and manage all providers in modules explicitly.

Interview Questions on This Topic

  • QAngular's dependency injector is hierarchical. If a service is provided at both the root injector and inside a lazy-loaded module's providers array, how many instances get created, and how does Angular decide which instance to inject into a component inside that lazy module?
  • QWhen would you choose component-level service providers over providedIn: 'root' in a production application β€” and what's the specific failure mode if you choose root when you should have chosen component-level?
  • QWhat happens to an active RxJS subscription inside an Angular service that uses providedIn: 'root' when the component that triggered the subscription is destroyed β€” and what's the production consequence of not handling this correctly?

Frequently Asked Questions

What is the difference between providedIn: 'root' and adding a service to a module's providers array?

providedIn: 'root' registers the service with the root injector as a singleton, is tree-shakeable (removed from the bundle if never injected), and works correctly across lazy-loaded modules. Adding a service to a module's providers array creates a new instance scoped to that module's injector, which breaks the singleton guarantee and can cause two separate instances to exist if the module is lazy-loaded. The rule of thumb: use providedIn: 'root' for everything shared app-wide; use providers: [] at the component level only when you explicitly need per-component instance isolation.

What is the difference between an Angular module's declarations and exports arrays?

declarations lists components, directives, and pipes that belong to this module's compiler β€” they can be used inside this module's templates. exports makes a subset of those declarations (or imported modules) available to any module that imports this one. A component in declarations but not exports is private to the module. The practical rule: only export what other modules actually need to consume. Exporting everything inflates every consumer's compilation context and adds to bundle analysis noise.

How do I stop an Angular service from leaking memory when it uses an RxJS interval or timer?

Use a Subject as a destroy signal and pipe takeUntil(this.destroy$) onto every observable inside the service. In ngOnDestroy, call this.destroy$.next() and this.destroy$.complete(). For root-provided services, ngOnDestroy fires on app teardown. For component-scoped services (provided in the component's providers array), ngOnDestroy fires when the host component is destroyed β€” which is why component-scoped providers are the correct choice for services tied to a component's lifecycle, like a polling service that should stop when the user navigates away.

Why does OnPush change detection sometimes show stale data even after state has clearly changed?

OnPush only re-renders when an @Input reference changes, an event originates inside the component, an async pipe emits, or change detection is manually triggered via ChangeDetectorRef.markForCheck(). If you mutate an object that's passed as @Input β€” e.g., order.status = 'CONFIRMED' β€” the reference hasn't changed, so OnPush skips the render. The fix is always immutable updates: pass a new object ({ ...order, status: 'CONFIRMED' }) so the reference comparison returns false and Angular schedules a re-render. This is the most common OnPush bug in production codebases, and it's silent β€” no error, just a view that shows data from five minutes ago.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousWebMCP β€” AI Tool Integration for the Web
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged