Angular
·5 min read·↗

How to Share Data Between Components in Angular (Signals & Services)

Main cover illustration for article: How to Share Data Between Components in Angular (Signals & Services)
Summarize with AI:

When building applications in Angular, sharing data between components is a fundamental requirement. Whether you need to pass an item down to a child card, notify a parent when a user clicks a button, or sync shopping cart state across unrelated header and sidebar components, Angular provides several distinct communication patterns.

⚡ Using Modern Angular (v17+)? Jump directly to Modern Angular: Signals input(), output(), model(), and Signal Services to see the idiomatic, zero-leak approach.

In this guide, we will compare the classic patterns (@Input, @Output, BehaviorSubject) with the modern Angular primitives (input(), output(), model(), and Signal Services).

Let's start with direct Parent-to-Child and Child-to-Parent communication.


1. Parent-to-Child & Child-to-Parent Communication 🔄

When two components have a direct hierarchical relationship, passing data down and emitting events up is the cleanest approach.

Legacy Angular (v2 – v16): @Input() & @Output() Decorators

In older versions of Angular, we used decorators and EventEmitter:

// child.component.ts (Legacy)
import { Component, Input, Output, EventEmitter } from '@angular/core';
 
export interface Product {
  id: number;
  name: string;
  price: number;
}
 
@Component({
  selector: 'app-product-card',
  template: `
    <div class="product-card">
      <h3>{{ product?.name }}</h3>
      <p>\${{ product?.price }}</p>
      <button (click)="selectProduct()">Select</button>
    </div>
  `
})
export class ProductCardComponent {
  @Input() product?: Product;
  @Output() selected = new EventEmitter<Product>();
 
  selectProduct() {
    if (this.product) {
      this.selected.emit(this.product);
    }
  }
}

Usage in the parent component template:

<app-product-card
  [product]="currentProduct"
  (selected)="onProductSelected($event)"
/>

Now let's see how modern Angular simplifies this with Signals.


2. Modern Angular (v17+): input(), output(), and model() ⚡

Modern Angular replaces property decorators with reactive Signal functions.

Modern Child Component with Signal Inputs and Outputs

// child.component.ts (Modern Standalone)
import { Component, input, output } from '@angular/core';
 
export interface Product {
  id: number;
  name: string;
  price: number;
}
 
@Component({
  selector: 'app-product-card',
  standalone: true,
  template: `
    <div class="product-card">
      <h3>{{ product().name }}</h3>
      <p>\${{ product().price }}</p>
      <button (click)="selectProduct()">Select</button>
    </div>
  `
})
export class ProductCardComponent {
  // 1. Required signal input (type-safe at compile time!)
  readonly product = input.required<Product>();
 
  // 2. Output function (no EventEmitter instantiation boilerplate)
  readonly selected = output<Product>();
 
  selectProduct() {
    this.selected.emit(this.product());
  }
}

Two-Way Data Binding with model() (Angular 17.2+)

If you need two-way binding between parent and child (e.g. an expanded panel state or quantity counter), use model():

// quantity-picker.component.ts
import { Component, model } from '@angular/core';
 
@Component({
  selector: 'app-quantity-picker',
  standalone: true,
  template: `
    <div class="flex gap-2">
      <button (click)="quantity.set(quantity() - 1)">-</button>
      <span>{{ quantity() }}</span>
      <button (click)="quantity.set(quantity() + 1)">+</button>
    </div>
  `
})
export class QuantityPickerComponent {
  // Writable two-way signal!
  readonly quantity = model(1);
}

Parent template usage:

<!-- Automatically synchronizes both ways! -->
<app-quantity-picker [(quantity)]="itemCount" />

Now, what if components are siblings or not directly connected in the template hierarchy? Let's explore Shared Services.


3. Cross-Component Communication with Services 🌐

When components live in separate branches of your application or across router outlets, passing inputs through multiple layers (prop drilling) becomes unmaintainable. An Injectable Service solves this cleanly.

Approach A: Legacy BehaviorSubject Service (RxJS)

// cart.service.ts (Legacy RxJS)
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
 
@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items$ = new BehaviorSubject<Product[]>([]);
  readonly currentItems$ = this.items$.asObservable();
 
  addItem(product: Product) {
    const updated = [...this.items$.value, product];
    this.items$.next(updated);
  }
}

In components, you had to manage subscriptions with async pipe or manually unsubscribe to prevent memory leaks:

<header>Cart Items: {{ (cartService.currentItems$ | async)?.length }}</header>

Approach B: Modern Signal-Based State Service (Zero Leaks, No Async Pipe)

In modern Angular, Signal Services provide synchronous, readable state with zero subscription management:

// cart.service.ts (Modern Signal State)
import { Injectable, signal, computed } from '@angular/core';
import { Product } from './product.model';
 
@Injectable({ providedIn: 'root' })
export class CartService {
  // 1. Private writable signal
  private readonly itemsSignal = signal<Product[]>([]);
 
  // 2. Public read-only signal
  readonly items = this.itemsSignal.asReadonly();
 
  // 3. Computed derived state (auto-recalculates when items change!)
  readonly totalCount = computed(() => this.items().length);
  readonly totalPrice = computed(() =>
    this.items().reduce((acc, item) => acc + item.price, 0)
  );
 
  addItem(product: Product) {
    this.itemsSignal.update((current) => [...current, product]);
  }
 
  removeItem(productId: number) {
    this.itemsSignal.update((current) => current.filter((p) => p.id !== productId));
  }
}

Consuming the Signal Service in Any Component

// cart-badge.component.ts
import { Component, inject } from '@angular/core';
import { CartService } from './cart.service';
 
@Component({
  selector: 'app-cart-badge',
  standalone: true,
  template: `
    <div class="badge">
      🛒 {{ cart.totalCount() }} items (\${{ cart.totalPrice() }})
    </div>
  `
})
export class CartBadgeComponent {
  readonly cart = inject(CartService);
}

4. Querying Direct Child Components with viewChild() 🔍

When a parent component needs direct access to a child component's public methods or properties:

import { Component, viewChild, AfterViewInit } from '@angular/core';
import { ProductCardComponent } from './product-card.component';
 
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductCardComponent],
  template: `<app-product-card #card [product]="featuredProduct" />`
})
export class ProductListComponent {
  // Modern Signal-based viewChild (replaces @ViewChild decorator)
  readonly card = viewChild.required<ProductCardComponent>('card');
 
  focusCard() {
    console.log('Accessing child component instance:', this.card());
  }
}

Communication Matrix: When to Use Which Pattern 📊

ScenarioRecommended PatternModern Angular Primitive
Parent to ChildOne-way data flowinput() or input.required()
Child to ParentCustom event notificationoutput()
Two-way synchronizationMutual state syncmodel()
Unrelated / Sibling ComponentsShared singleton servicesignal() + computed() service
Parent accessing Child methodsDirect component referenceviewChild() signal

Recap 🛠️

To write clean, maintainable Angular applications:

  1. Use input() and output() for standard parent-child communication.
  2. Use model() for two-way state synchronization without paired @Input/@Output ceremony.
  3. Use Signal Services (signal(), asReadonly(), computed()) to share global and cross-component state without memory leaks.

For more modern Angular architectural patterns, check out my articles on How to Simplify Component Communication with model() and Signals and How to Use ng-template, ng-container, and ng-content!

Part of the Angular Series

These are my experiences learning and facing my daily challenges working with Angular.

View Entire Series

Frequently Asked Questions

What is the difference between legacy @Input() and modern Signal input() in Angular?

Legacy @Input() is a property decorator that relies on Zone.js and component lifecycle hooks to detect changes. Modern Signal input() (available in Angular 17.1+) returns a reactive Signal that integrates directly with computed() signals, enforces strict type safety with input.required(), and eliminates ChangeDetectionStrategy.OnPush edge cases.

How do you share two-way bound data between parent and child components in modern Angular?

Use the model() function (introduced in Angular 17.2+). It creates a writable signal in the child component that automatically synchronizes with a two-way [(property)] binding in the parent, eliminating the need to manually pair an @Input() with an @Output() EventEmitter.

When should I use a Service instead of Input/Output to share data in Angular?

Use Input/Output (or Signals input/output) for direct parent-child communication. When components are siblings, deeply nested across multiple router outlets, or need to share global application state, use an Injectable Service with Signals or RxJS.

How do Signal-based state services replace BehaviorSubject services in modern Angular?

Instead of creating a private BehaviorSubject with an asObservable() stream and managing manual subscriptions, you create a private signal() and expose it as a read-only asReadonly() signal. This gives components synchronous, zero-leak reactivity without needing async pipes or unsubscribe logic.

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.