Unidirectional Data Flow in Angular: Signals, input/output and State in Services
Unidirectional data flow in Angular means the state has a single owner, data goes down with input(), change requests go up with output(), and derived values are computed with computed(). With real tests we show how state written by an effect lags behind, how model() bypasses a business rule and why default OnPush misses an array changed in place, then move the state into a service that holds signals.
Version note: The examples in this post were tested with Angular 22 and TypeScript.
Guess First
In the previous post we built unidirectional data flow without a framework and saw the glitch you get from writing derived values into the state. In Angular the most common form of the same mistake is this: when one signal changes, update another signal with effect(). The component below computes the total in two ways; one is a signal written by an effect, the other is computed().
import { Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-price-preview',
template: `{{ totalFromEffect() }} / {{ totalFromComputed() }}`,
})
export class PricePreview {
readonly quantity = signal(1);
// Bad example: writing derived state with an effect
readonly totalFromEffect = signal(0);
// The right way: derive the state with computed
readonly totalFromComputed = computed(() => this.quantity() * 14);
constructor() {
effect(() => this.totalFromEffect.set(this.quantity() * 14));
}
}
And here is the test:
import { TestBed } from '@angular/core/testing';
import { PricePreview } from './price-preview';
describe('PricePreview', () => {
it('effect lags behind, computed does not', async () => {
const fixture = TestBed.createComponent(PricePreview);
await fixture.whenStable();
const preview = fixture.componentInstance;
preview.quantity.set(3);
const read = `${preview.totalFromEffect()} / ${preview.totalFromComputed()}`;
TestBed.tick();
const afterTick = `${preview.totalFromEffect()} / ${preview.totalFromComputed()}`;
console.log(`read=${read} afterTick=${afterTick}`);
expect(read).toBe('14 / 42');
expect(afterTick).toBe('42 / 42');
});
});
What is the value of read, taken right after the quantity.set(3) line? What is afterTick, taken after TestBed.tick()? If you think both approaches give the same result on screen, also try to guess where the difference will show up.
Data Down, Events Up
The basic rule of unidirectional data flow in Angular fits into four words: data down, events up. The parent component owns the state and passes the value to the child with input(). When the child wants something to change, it does not change the value itself; it notifies the parent with output(). The parent decides, updates the state, and the new value comes back down. This is the component-tree counterpart of the action → store → view loop in Flux.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-quantity-stepper',
template: `
<button type="button" (click)="quantityChange.emit(quantity() - 1)">-</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="quantityChange.emit(quantity() + 1)">+</button>
`,
})
export class QuantityStepper {
// Data down: the parent provides the value
readonly quantity = input.required<number>();
// Event up: proposes the new value to the parent, does not change it itself
readonly quantityChange = output<number>();
}
QuantityStepper does not own the quantity; it only displays it. When a button is clicked it says "let the new value be this" and stops there. The max-10 rule, a stock limit or resetting the quantity when the book changes are not this component's problem. Since the signal returned by input() is read-only, writing this.quantity.set(5) in the child does not even compile; the framework enforces ownership in code.
This model has a surprising effect the first time you test it. When we clicked the button 12 times in a row in a test without waiting for a render in between, the quantity stopped at 2. The reason is simple: on every click the child proposes quantity() + 1, but quantity() keeps its old value until the parent re-renders and passes the new value down. Real users get a render between clicks, so this does not happen in the browser; but it shows well that what the child sees is always the value the parent gave it, never its own guess.
Picking a book works the same way:
import { Component, input, output } from '@angular/core';
import type { Book } from './book';
@Component({
selector: 'app-book-picker',
template: `
@for (book of books(); track book.id) {
<button type="button" [class.selected]="book.id === selectedId()" (click)="bookSelected.emit(book)">
{{ book.title }}
</button>
}
`,
})
export class BookPicker {
readonly books = input.required<readonly Book[]>();
readonly selectedId = input<number | null>(null);
readonly bookSelected = output<Book>();
}
Note the output names: bookSelected, quantityChange. The Angular documentation recommends not prefixing outputs with on, avoiding names that collide with DOM (Document Object Model) events (click, change) and using camelCase. Angular outputs do not bubble up like DOM events; the event goes only to the direct parent. That is another constraint that keeps the flow traceable: you can see from the template who sends an event to whom.
When to Use model()
model(), introduced in Angular 17.2, declares an input and its matching output in one line. The component can write its own value with set or update, and Angular passes that write to the parent through the automatically created quantityChange output:
import { Component, model } from '@angular/core';
@Component({
selector: 'app-quantity-input',
template: `
<button type="button" (click)="quantity.update(q => q + 1)">+</button>
<span>{{ quantity() }}</span>
`,
})
export class QuantityInput {
// model(): an input plus an automatic quantityChange output
readonly quantity = model.required<number>();
}
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { QuantityInput } from './quantity-input';
@Component({
imports: [QuantityInput],
template: `<app-quantity-input [(quantity)]="quantity" />`,
})
class Host {
readonly quantity = signal(10);
}
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/quantity-input.spec.ts
stdout | src/app/p07/quantity-input.spec.ts > QuantityInput > writes straight into the parent signal
parent quantity: 11
✓ |bookstore| src/app/p07/quantity-input.spec.ts > QuantityInput > writes straight into the parent signal
Let's be clear about one thing first: Angular's [( )] syntax is not AngularJS's two-way binding. The compiler expands it into a property binding and an event binding; at runtime data still goes down and events still go up. There is no digest loop and no chain of watchers triggering each other. So model() does not break the flow; it shortens it.
But look at the test result: the parent starts at 10, we click the plus button once, and the quantity in the parent becomes 11. In the reducer from the previous post, and in the state service we are about to write, the quantity is capped at 10; here the child writes straight into the parent's signal, so the rule never runs. The natural home of model() is a form-control-like leaf component that is the sole owner of its value: a date picker, a color picker, the open state of a panel that opens and closes on its own. The Angular documentation also uses custom form controls as its example. If there is a business rule behind the value, sending a "proposed new value" with an output keeps the rules in one place, instead of handing the child a writable signal.
Derived State with computed()
The real output of the test from the opening:
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/price-preview.spec.ts
stdout | src/app/p07/price-preview.spec.ts > PricePreview > effect lags behind, computed does not
read=14 / 42 afterTick=42 / 42
✓ |bookstore| src/app/p07/price-preview.spec.ts > PricePreview > effect lags behind, computed does not
Right after quantity.set(3), computed returns 42 while the signal written by the effect still shows 14. Effects do not run synchronously; Angular schedules them and runs them during change detection. In the test, TestBed.tick() flushes that schedule and the value catches up to 42. In the meantime everyone who reads it (another computed, a service method, a test) sees the old value. It is the same problem as the total=42.00 discount=0.00 line in the previous post, only this time with Angular's timing.
computed() does not have this problem by design. According to the Angular documentation, computed signals are lazily evaluated and memoized: when read, they recalculate if one of their dependencies changed, and return the cached value otherwise. Since there is no time gap between reading and computing, there is no intermediate state either. The rule is simple: if a value can be derived from other signals, do not write it into the state; derive it with computed().
Sometimes the derived value also has to be changeable by the user. When the selected book changes, the quantity should go back to 1, but in between the user should be able to increase it. Instead of writing an effect for this, there is linkedSignal(), introduced in Angular 19:
import { Component, input, linkedSignal } from '@angular/core';
import type { Book } from './book';
@Component({
selector: 'app-book-line',
template: `{{ book().title }} x{{ quantity() }}`,
})
export class BookLine {
readonly book = input.required<Book>();
// Goes back to 1 when the book changes; the user can change it in between
readonly quantity = linkedSignal({ source: this.book, computation: () => 1 });
}
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/book-line.spec.ts
stdout | src/app/p07/book-line.spec.ts > BookLine > resets the quantity when the book changes
The Hobbit x3 -> Emma x1
✓ |bookstore| src/app/p07/book-line.spec.ts > BookLine > resets the quantity when the book changes
effect() Only for Side Effects
The Angular documentation explicitly positions effects as a "last resort" and says they should not be used to move state from one signal to another, because that can lead to ExpressionChangedAfterItHasBeenChecked errors, circular updates and unnecessary change detection cycles. Since Angular 19, writing to a signal inside an effect no longer needs a separate permission (allowSignalWrites); the compiler no longer stops you, so the decision is yours.
The legitimate job of an effect is to reflect a change in the signal world into a world without signals: writing to localStorage, logging, updating a chart library that does not know signals, touching the DOM by hand. The effect that saves the draft to the browser in the state service in the next section is exactly of this kind. Ask yourself: at the end of the effect, am I writing to a signal or to something outside? If it is the former, what you are looking for is most likely computed() or linkedSignal().
Moving State into a Service
Once the order form spreads from a single component across a page (cart summary in the header, the form in the middle, a stock warning somewhere else), keeping the state in one component is not enough. The simplest solution in Angular is a service that holds signals: the store from the previous post, written with signals.
import { Injectable, computed, effect, signal } from '@angular/core';
import type { Book } from './book';
interface OrderDraft {
readonly book: Book | null;
readonly quantity: number;
}
@Injectable({ providedIn: 'root' })
export class OrderDraftStore {
// State lives in one place and is closed to the outside
private readonly state = signal<OrderDraft>({ book: null, quantity: 1 });
// Reading: read-only signals
readonly book = computed(() => this.state().book);
readonly quantity = computed(() => this.state().quantity);
readonly subtotal = computed(() => (this.book()?.price ?? 0) * this.quantity());
readonly discount = computed(() => (this.quantity() >= 3 ? this.subtotal() / 10 : 0));
readonly total = computed(() => this.subtotal() - this.discount());
constructor() {
// Side effect: saving the draft to the browser is a job for an effect
effect(() => localStorage.setItem('order-draft', JSON.stringify(this.state())));
}
// Writing: the rules live only here
selectBook(book: Book): void {
this.state.update(s => ({ ...s, book, quantity: 1 }));
}
changeQuantity(quantity: number): void {
const clamped = Math.min(Math.max(quantity, 1), 10);
this.state.update(s => ({ ...s, quantity: clamped }));
}
}
Mapped to the pieces from the previous post, it looks like this:
| Flux / Redux | Service holding signals |
|---|---|
| State in the store | private readonly state = signal(...) |
| Selector | Read-only signals exposed with computed() |
| Action + reducer | Public methods such as selectBook(), changeQuantity() |
| Subscribe and render | Reading signals in the template; Angular tracks them itself |
| Middleware / effect | effect() or async work inside a method |
The critical point is that the writable signal is private. Only read-only signals derived with computed() go out; the only way to change the state is through the service's methods. The "state is read-only, changes come through a single door" principle from the previous post is enforced here by TypeScript's access modifiers. When a rule changes in the service (the discount threshold, the quantity limit), this is the only file you touch.
The container component reads the state and forwards the events coming from its children to the service:
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { BOOKS } from './book';
import { BookPicker } from './book-picker';
import { OrderDraftStore } from './order-draft-store';
import { QuantityStepper } from './quantity-stepper';
@Component({
selector: 'app-order-form',
imports: [BookPicker, QuantityStepper, CurrencyPipe],
template: `
<app-book-picker [books]="books" [selectedId]="store.book()?.id ?? null" (bookSelected)="store.selectBook($event)" />
@if (store.book()) {
<app-quantity-stepper [quantity]="store.quantity()" (quantityChange)="store.changeQuantity($event)" />
<p class="total">{{ store.total() | currency: 'EUR' }}</p>
}
`,
})
export class OrderForm {
protected readonly store = inject(OrderDraftStore);
protected readonly books = BOOKS;
}
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/order-form.spec.ts
stdout | src/app/p07/order-form.spec.ts > OrderForm > flows data down and events up
10 €126.00
✓ |bookstore| src/app/p07/order-form.spec.ts > OrderForm > flows data down and events up
In the test we picked a book and clicked the plus button 12 times (waiting for a render after each click); the quantity stopped at 10 and the total, with the 10 percent discount, became 126 euros. The service's own test runs without creating a single component in TestBed:
import { TestBed } from '@angular/core/testing';
import { BOOKS } from './book';
import { OrderDraftStore } from './order-draft-store';
describe('OrderDraftStore', () => {
it('derives totals and saves the draft', () => {
const store = TestBed.inject(OrderDraftStore);
store.selectBook(BOOKS[0]);
store.changeQuantity(3);
TestBed.tick();
console.log(`total=${store.total()} saved=${localStorage.getItem('order-draft')}`);
expect(store.total()).toBe(37.8);
});
});
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/order-draft-store.spec.ts
stdout | src/app/p07/order-draft-store.spec.ts > OrderDraftStore > derives totals and saves the draft
total=37.8 saved={"book":{"id":1,"title":"The Hobbit","price":14},"quantity":3}
✓ |bookstore| src/app/p07/order-draft-store.spec.ts > OrderDraftStore > derives totals and saves the draft
A Quick Look at NgRx Signal Store
The standardized form of this pattern is NgRx Signal Store. In @ngrx/signals 22.0.1, the version compatible with Angular 22, the same store is written like this:
import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import type { Book } from './book';
interface OrderDraft {
book: Book | null;
quantity: number;
}
export const OrderDraftSignalStore = signalStore(
{ providedIn: 'root' },
withState<OrderDraft>({ book: null, quantity: 1 }),
withComputed(({ book, quantity }) => {
const subtotal = computed(() => (book()?.price ?? 0) * quantity());
const discount = computed(() => (quantity() >= 3 ? subtotal() / 10 : 0));
return { subtotal, discount, total: computed(() => subtotal() - discount()) };
}),
withMethods(store => ({
selectBook(book: Book): void {
patchState(store, { book, quantity: 1 });
},
changeQuantity(quantity: number): void {
patchState(store, { quantity: Math.min(Math.max(quantity, 1), 10) });
},
})),
);
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/order-draft-signal-store.spec.ts
stdout | src/app/p07/order-draft-signal-store.spec.ts > OrderDraftSignalStore > applies the same rules as the hand-written store
quantity=10 total=126
✓ |bookstore| src/app/p07/order-draft-signal-store.spec.ts > OrderDraftSignalStore > applies the same rules as the hand-written store
The pieces are familiar: withState defines the state, withComputed the selectors, withMethods the actions, and the state is updated only with patchState, by producing a new object. The store's state is protected by default. If a component takes a shortcut and tries to patch the state from outside:
protected setTen(): void {
// Bad example: skipping the store's methods and patching the state from outside
patchState(this.store, { quantity: 10 });
}
$ npx tsc -p tsconfig.app.json --noEmit
src/app/p07/quick-fix.ts(14,20): error TS2345: Argument of type '{ book: DeepSignal<Book> | Signal<null>; quantity: Signal<number>; subtotal: Signal<number>; discount: Signal<number>; total: Signal<...>; selectBook: (book: Book) => void; changeQuantity: (quantity: number) => void; } & StateSource<...>' is not assignable to parameter of type 'WritableStateSource<{ book: Book | null; quantity: number; }>'.
The types of '[STATE_SOURCE].book' are incompatible between these types.
Type 'Signal<Book | null>' is missing the following properties from type 'WritableSignal<Book | null>': set, update, asReadonly, [ɵWRITABLE_SIGNAL]
NgRx gives at the type level the protection we got with private in the hand-written service. So when do you need it? In apps where several stores are written with the same pattern, entity collections are managed (withEntities), RxJS-based streams are connected to the store (rxMethod), and lifecycle hooks and a shared structure across the team are needed. For the draft of a single form, the service above is more than enough. The full coverage of NgRx's Store, Effects and Signal Store APIs (application programming interfaces) belongs to the upcoming State Management in Angular series in the Frontend category; here it is enough to see that the pattern is the same.
Default OnPush in Angular 22 and This Model
Since Angular 22, every component that does not specify a strategy behaves as OnPush; the old behavior is now an option you have to request explicitly as ChangeDetectionStrategy.Eager. OnPush checks a component only in specific cases: when its input receives a new reference, when a signal read in its template changes, when an event happens inside it, or when it is explicitly marked. These rules line up one to one with unidirectional data flow. Let's try the same child with two different parents:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-cart-count',
template: `{{ titles().length }} items`,
})
export class CartCount {
readonly titles = input.required<readonly string[]>();
}
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CartCount } from './cart-count';
@Component({
imports: [CartCount],
template: `<app-cart-count [titles]="titles" /><button (click)="add()">add</button>`,
})
class MutatingCart {
readonly titles: string[] = ['The Hobbit'];
add(): void {
// Push to the same array: the reference does not change
this.titles.push('Emma');
}
}
@Component({
imports: [CartCount],
template: `<app-cart-count [titles]="titles()" /><button (click)="add()">add</button>`,
})
class ImmutableCart {
readonly titles = signal<readonly string[]>(['The Hobbit']);
add(): void {
// New array: the reference changes
this.titles.update(list => [...list, 'Emma']);
}
}
$ npx ng test --watch=false --reporters=verbose --include src/app/p07/cart-count.spec.ts
✓ |bookstore| src/app/p07/cart-count.spec.ts > CartCount with default OnPush > mutating parent shows 1 items
✓ |bookstore| src/app/p07/cart-count.spec.ts > CartCount with default OnPush > immutable parent shows 2 items
With the parent that changes the array in place, the child keeps showing "1 items": the parent is checked because of its own event, but since the reference passed to the child is the same, Angular skips the child. With the parent that produces a new array, the signal changes, a new reference goes to the input, and the child updates. This is the Angular counterpart of the "no change means the same reference" rule from the previous post: immutable updates are how OnPush gets to see a change.
In an app built with signals, state held in services and immutable updates, the switch to default OnPush often goes unnoticed. Where it gets noticed is old code that changes state in place. That is why ng update adding Eager to old components when you upgrade to Angular 22 is a migration aid; the goal is a data flow that lets you remove those lines over time.
Common Pitfalls
Writing state inside an effect. The test from the opening is the smallest example. If an effect reads one signal and writes another, that value should be either computed() or linkedSignal(). An effect should end somewhere that is not a signal.
Using model() everywhere. In a tree bound with [(...)] at every level, everyone can write everyone else's value and the rules scatter into the children; it is the signal-based version of the "Smart UI" problem from the first post (all logic buried in the UI, or user interface, code). Leave model() to leaf components that are the sole owners of their value.
Exposing a writable signal from a service. A public writable signal such as readonly quantity = signal(1) is a back door that skips the service's methods. Expose read-only signals with computed() or asReadonly().
Treating an input as state. Trying to change an input inside the child (still possible with the decorator-based @Input()) leads the parent and the child to keep two different copies of the same value. The signal-based input() prevents this at compile time; if you need a local copy, use linkedSignal().
Moving everything into a service. Whether a dropdown is open, or the unsaved value of an input, is the component's own state. Put state next to the nearest common owner that reads it.
A Small Experiment
★☆☆ Remove the effect from PricePreview, delete totalFromEffect and rewrite the test with computed only. Then add a shipping fee signal to the component (5 if the total is under 50) and derive that with computed too.
★★☆ You want to use QuantityInput in OrderForm without breaking the limit of 10. Try two ways: adding a max input next to model(), and using input + output instead of model() and leaving the decision to the store. In which one does the rule stay in a single place?
★★★ Add a submit() method to OrderDraftStore: the status should go to submitting, an HTTP (Hypertext Transfer Protocol) request should be sent, the status should become submitted when the response arrives, and the draft should be removed from localStorage. Write the same store with NgRx Signal Store and compare the tests of the two versions.
In this post we built the flow with Angular's own tools and moved the state into a service. In the next post we look at the component tree itself: which component should know about the state, and which should only take inputs and emit events? We will cover the container and presentational component split, designing a component's public API as a contract, and content projection and composition.