Angular Component Communication: Signal Inputs, Outputs, and Shared State
A component alone is easy. Applications are conversations โ a parent configuring children, children reporting events upward, siblings needing the same data, a header needing to know what a deeply-nested cart contains. Choosing the right channel for each conversation is most of Angular architecture; choosing wrong is how apps become webs of tangled references nobody can refactor.
This guide covers the modern toolkit โ signal input(), output(), model(), view queries โ and the decision framework for when parent-child patterns stop being enough.
Inputs: Data Flows Down
The child declares what it accepts; the parent binds values in:
import { Component, input, computed } from '@angular/core';
@Component({ selector: 'app-cart-item-row', template: ` <div class="row" [class.bulk]="isBulk()"> {{ item().name }} ร {{ item().qty }} โ {{ lineTotal() / 100 }} </div> `,})export class CartItemRow { item = input.required<CartItem>(); // required: compiler enforces binding discountPct = input(0); // optional, with default
lineTotal = computed(() => item().paisePerUnit * item().qty * (1 - this.discountPct() / 100)); isBulk = computed(() => this.item().qty >= 10);}<!-- parent --><app-cart-item-row [item]="line" [discountPct]="memberDiscount()" />The modern details:
input()returns a signal โ read with(), like any signal. When the parentโs value changes, the input signal updates and everythingcomputedfrom it recalculates automatically. This replaced the old@Input()property +ngOnChangesdance with plain reactive derivation โlineTotalabove would have been a hand-synchronized method in legacy code.input.required<T>()โ omitting the binding is a compile error. Use it for anything the component is meaningless without; defaults for genuine options. (Legacy@Input() item!: Productwith the!was a runtime prayer; required inputs made it a contract.)- Inputs are one-way by contract. The child never assigns to an input โ and with immutable-minded parents passing new objects on change, data flow stays traceable: every value on screen has one owner, above.
Transform functions handle input normalization declaratively: disabled = input(false, { transform: booleanAttribute }) lets callers write bare disabled attributes HTML-style.
Outputs: Events Flow Up
Children donโt call parents โ they announce, and parents decide what it means:
// childexport class CartItemRow { item = input.required<CartItem>(); remove = output<string>(); // event carrying the item id qtyChange = output<number>();
onRemoveClick() { this.remove.emit(this.item().id); }}<!-- parent template --><app-cart-item-row [item]="line" (remove)="cart.removeItem($event)" (qtyChange)="cart.setQty(line.id, $event)"/>output<T>() declares the event; .emit(value) fires it; the parent binds with ( ) and receives the payload as $event โ the same syntax as DOM events, deliberately. Design guidance that keeps components reusable:
- Name outputs as events, not commands โ
(remove),(selectionChange), not(doRemoveFromCart). The child reports what happened; only the parent knows what to do about it โ that ignorance is precisely what makes the child reusable in other contexts. - Emit meaningful payloads โ the id or the changed value, not the raw DOM event, so parents never reach into child internals.
- Under the hood
output()replaced@Output() x = new EventEmitter<T>()โ same wire format, less ceremony, no RxJS import. Youโll read the old form everywhere.
model(): sanctioned two-way
When a componentโs whole point is editing a value โ inputs, sliders, toggles โ declare a model:
export class QtyStepper { qty = model.required<number>(); // input + output, fused
inc() { this.qty.update((q) => q + 1); } // child MAY write a model dec() { this.qty.update((q) => Math.max(1, q - 1)); }}<app-qty-stepper [(qty)]="line.qty" /> <!-- banana box works on YOUR component -->model() is the two-way sugar formalized: a writable signal that syncs both directions. The restraint rule: models are for value-editing widgets; broad two-way binding of business state re-creates the untraceable data flows one-way design exists to prevent.
View Queries: When the Parent Needs a Handle
Occasionally a parent must address a child imperatively โ focus it, open it, measure it:
export class CheckoutPage { addressForm = viewChild.required(AddressForm); // signal of the child instance submitBtn = viewChild<ElementRef>('submitRef'); // or a template ref variable
submitAll() { if (!this.addressForm().validate()) return; // ... }}viewChild() (and viewChildren()) return signals resolving to child component instances or elements โ the modern, timing-safe form of the old @ViewChild decorator with its lifecycle-timing gotchas. Legitimate uses: focus management, imperative APIs on widgets (open a dialog, reset a form), measurement. The smell: using queries to read child state routinely โ thatโs data that should flow via outputs or shared services. Queries are for commanding, sparingly; if parent and child converse through queries, the design has inverted.
Beyond Parent-Child: Services as Shared State
Inputs/outputs shine for adjacent components and degrade with distance โ passing data through four intermediate components that donโt care about it (โprop drillingโ) couples everyone to everyone. The Angular answer is a service holding shared state, injected by whoever needs it:
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })export class CartStore { private items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length); readonly totalPaise = computed(() => this.items().reduce((s, i) => s + i.paisePerUnit * i.qty, 0));
add(item: CartItem) { this.items.update((xs) => [...xs, item]); } removeItem(id: string) { this.items.update((xs) => xs.filter((x) => x.id !== id)); }}// any component, any depth:export class HeaderBadge { cart = inject(CartStore); // template: {{ cart.count() }} โ updates automatically, forever}The header badge, the cart page, and the product buttons all inject one CartStore instance and share its signals โ no drilling, no event relays, updates propagating reactively to every reader. Notice the encapsulation: the writable signal is private; the world gets read-only computeds and named mutation methods โ the invariant-guarding discipline at store scale. This service-with-signals pattern is modern Angularโs default state management, covered fully in the signals guide and DI guide.
The decision framework
parent โ direct child, data/config โ input()child โ parent, something happened โ output()widget edits one value โ model()parent commands child imperatively โ viewChild (sparingly)siblings / distant / many readers โ shared service with signalscaller supplies markup, not data โ content projection (ng-content)The reliable smells: state passed through components that donโt use it (โ service), outputs re-emitted up multiple layers (โ service), and sibling refs handed around so one can call the other (โ always a service).
Worked Refactor: From Prop Drilling to a Store
The decision framework is easiest to absorb watching one design evolve. Version one โ a product page where the buy button needs cart access:
ProductPage โ ProductInfo โ PriceBox โ BuyButton [cart] [cart] [cart] (uses it)Three components relay a cart input they never read, and each grows an output chain relaying (added) events back up. Every intermediate componentโs API is polluted by its descendantsโ needs โ the drilling smell in its purest form. Adding a wishlist button two levels deep elsewhere would double the plumbing.
Version two โ the store refactor:
// BuyButton, directly:export class BuyButton { private cart = inject(CartStore); product = input.required<Product>(); // still takes its OWN data as input
add() { this.cart.add(toCartItem(this.product())); }}ProductInfo and PriceBox lose the pass-through input and output entirely โ their APIs shrink back to what they render. The header badge elsewhere reads cart.count() with zero connection to this subtree. Note what didnโt move: product stays an input, because itโs this componentโs local rendering data with a clear parent owner โ the store took only the cross-cutting state.
The judgment line this example draws: inputs/outputs describe a componentโs relationship with its parent; stores describe stateโs relationship with the app. When a piece of dataโs readers and writers stop being ancestors and descendants of each other, it has outgrown the input system โ and moving it is a simplification of every component in between, which is how you know the timing was right. (The reverse migration matters too: a store holding state only one subtree uses is global-by-laziness โ push it down to a component-provided service or plain inputs.)
Frequently Asked Questions
When do I still need ngOnChanges? Rarely, in signal-era code โ reacting to input changes is what computed (derive) and effect (side effects) do naturally. ngOnChanges remains in legacy components with decorator inputs; the lifecycle guide covers the mapping.
Can a child inject its parent component? Yes (inject(CartPage)) โ element-hierarchy DI allows it โ and it couples the child to that exact parent, destroying reuse. Reserve it for tightly-bound component families (tabs + tab, accordion + panel) where the coupling is the design; otherwise use the patterns above.
How do sibling components communicate without a service? Through their common parent: Aโs output updates parent state, parent binds it into B. Thatโs fine for one or two hops; the moment it feels like relaying, that state has outgrown the parent โ service time.
What replaced @Input() set x(...) setter tricks? Deriving with computed(() => transform(this.x())) for derived values, effect for genuine side effects, and transform: on the input for normalization โ each intent now has a named tool instead of one overloaded setter idiom.
Do inputs work with OnPush / zoneless? Signal inputs are the foundation both are built on โ inputs are signals, so change detection knows exactly which components a change touches. This is a large part of why the migration to signal inputs matters beyond ergonomics.
Reading Legacy Communication Code
The decorator-era equivalents, mapped for the codebases youโll inherit: @Input() item!: Product (the ! asserting โtrust me, itโll be boundโ โ modern input.required made it a real contract); @Input() set qty(v) {...} setter-interception (โ computed/transform); @Output() remove = new EventEmitter<string>() (โ output() โ same .emit, same template binding, so mixed codebases feel seamless); @ViewChild('ref') el!: ElementRef with its ngAfterViewInit timing dance (โ viewChild signals, timing-safe); and BehaviorSubject-based shared services with async pipes at every consumer (โ signal stores โ the architectural shape is identical, only the reactive primitive changed). Migration schematics (signal-input-migration, output-migration) automate most of the mechanical conversions. The durable insight: the patterns โ data down, events up, stores for distance โ predate signals and survived them; only the syntax modernized. Reading a 2019 component, youโre reading this guideโs architecture in an older dialect.
A final design lens worth keeping: a componentโs inputs and outputs are its API, and API review standards apply. Would you approve a function with nine parameters, three of them booleans? Then a component with nine inputs deserves the same scrutiny โ group related inputs into one object input, or question whether the component is several components. Same for outputs that expose internals ((internalStateChanged)) rather than meaningful events. Components with clean, small APIs get reused; components with sprawling ones get copy-pasted and forked โ the codebase tells you which youโve built.
Self-Check
- A
UserAvatarcomponent crashes when parents forget[user]. Which API turns that runtime crash into a compile error? - Why should
CartItemRowemit(remove)with an id rather than callingcartService.removeItem(...)itself? [(value)]="query"on your customSearchBoxโ what mustSearchBoxdeclare for this to work, in both modern and pre-model() forms?- A settings toggle five levels deep must update a header indicator. Walk the two candidate designs and pick.
- A parent uses
viewChildto readchild.selectedItems()every render. Whatโs the smell, and the fix?
(Answers: input.required<User>(). Reusability and single ownership โ the row works in any list context when the parent decides meaning; also the row stays service-free and trivially testable. Modern: value = model<string>(); legacy: @Input() value + @Output() valueChange. Input-drilling through five layers vs a shared SettingsStore service โ service wins at that distance. Queries-as-data-channel; expose the selection via an output or shared store โ queries are for commands.)
Next: Lifecycle Hooks โ what Angular calls when, effect and afterNextRender in the signal era, and the cleanup discipline that prevents leaks.