Introduction

Signals shipped in Angular 16 and became the default reactivity model by Angular 18. Three primitives: signal() for writable state, computed() for derived values, effect() for side effects. That is the entire API surface. Everything here targets Angular 19.

The old model was zone.js intercepting every async event and triggering a full change detection sweep. Every setTimeout, every HTTP callback, every mouse move -- Angular would re-check the entire component tree looking for what changed. You could bolt on OnPush and scatter ChangeDetectorRef.markForCheck() through your codebase, but the framework was still guessing. Signals end the guessing. Angular tracks exactly which template binding reads which signal, and when a signal changes, only those bindings update. Nothing else gets touched.

Signals are synchronous. Always hold a current value. You read by calling the signal as a function -- no subscriptions, no timing issues where an observable emits before anything is listening, no two streams racing to produce stale UI. That alone kills a whole class of bugs that plagued RxJS-heavy Angular code. And if you have used SolidJS or Vue's reactivity system, this will feel familiar.

Creating and Reading Signals

signal(0) creates it. count() reads it. .set() replaces the value, .update() derives the new value from the old one. For objects and arrays, always return a new reference -- Angular checks reference equality, so mutating in place won't trigger updates.

counter.component.ts
import { Component, signal } from'@angular/core';
@Component({
 selector: 'app-counter',
 standalone: true,
 template: `
 <div class="counter">
 <h2>Count: {{ count() }}</h2>
 <button (click)="increment()">Increment</button>
 <button (click)="decrement()">Decrement</button>
 <button (click)="reset()">Reset</button>
 </div>
 `
})
export classCounterComponent {
 // Create a writable signal with an initial value of 0
 count = signal(0);
 increment() {
 // update() receives the current value and returns the new onethis.count.update(current => current + 1);
 }
 decrement() {
 this.count.update(current => current - 1);
 }
 reset() {
 // set() replaces the value directlythis.count.set(0);
 }
}

Computed Signals

Computed signals are where things get interesting. A computed() takes a function that reads other signals and returns a derived value. Angular tracks which signals the function reads, and only re-runs it when those specific dependencies change. Lazy and cached -- read it ten times in a row with no dependency changes, and the derivation function runs once. You can also chain them: a computed signal can depend on other computed signals, and Angular figures out the correct recalculation order automatically. No more stale derived properties calculated in ngOnChanges in the wrong sequence. No more manual bookkeeping. The shopping cart below has subtotal feeding into tax feeding into total, and you never think about update ordering.

shopping-cart.component.ts
import { Component, signal, computed } from'@angular/core';
interface CartItem {
 name: string;
 price: number;
 quantity: number;
}
@Component({
 selector: 'app-shopping-cart',
 standalone: true,
 template: `
 <div class="cart">
 <h2>Shopping Cart</h2>
 @for (item of items(); track item.name) {
 <div class="cart-item">
 <span>{{ item.name }}</span>
 <span>{{ item.quantity }} x ${{ item.price }}</span>
 </div>
 }
 <hr />
 <p>Items: {{ totalItems() }}</p>
 <p>Subtotal: ${{ subtotal().toFixed(2) }}</p>
 <p>Tax (8%): ${{ tax().toFixed(2) }}</p>
 <p><strong>Total: ${{ total().toFixed(2) }}</strong></p>
 </div>
 `
})
export classShoppingCartComponent {
 items = signal<CartItem[]>([
 { name: 'TypeScript Handbook', price: 29.99, quantity: 1 },
 { name: 'Angular Stickers', price: 4.99, quantity: 3 },
 ]);
 // Computed signals derive from other signals automatically
 totalItems = computed(() =>this.items().reduce((sum, item) => sum + item.quantity, 0)
 );
 subtotal = computed(() =>this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
 );
 // Computed signals can depend on other computed signals
 tax = computed(() =>this.subtotal() * 0.08);
 total = computed(() =>this.subtotal() + this.tax());
 addItem(item: CartItem) {
 this.items.update(current => [...current, item]);
 }
}

Standard stuff.

Computed signals are read-only. No .set(), no .update(). If you feel the urge to directly override a computed value, your state model needs rethinking.

Effects and Side Effects

Effects run code that talks to the outside world when signals change. Logging. localStorage. Analytics. Do not use them to update other signals -- that is what computed() is for, and Angular will yell at you if you try.

theme-manager.component.ts
import { Component, signal, effect } from'@angular/core';
@Component({
 selector: 'app-theme-manager',
 standalone: true,
 template: `
 <div>
 <p>Current theme: {{ theme() }}</p>
 <button (click)="toggleTheme()">Toggle Theme</button>
 <label>
 Font size: {{ fontSize() }}px
 <input
 type="range"
 min="12"
 max="24"
 [value]="fontSize()"
 (input)="fontSize.set(+$event.target.value)"
 />
 </label>
 </div>
 `
})
export classThemeManagerComponent {
 theme = signal<'light' | 'dark'>('light');
 fontSize = signal(16);
 constructor() {
 // This effect runs whenever theme() or fontSize() changeseffect(() => {
 const currentTheme = this.theme();
 const currentFontSize = this.fontSize();
 // Side effect: update the DOM directly
 document.documentElement.setAttribute('data-theme', currentTheme);
 document.documentElement.style.fontSize = `${currentFontSize}px`;
 // Side effect: persist to localStorage
 localStorage.setItem('user-preferences', JSON.stringify({
 theme: currentTheme,
 fontSize: currentFontSize
 }));
 console.log(`Preferences saved: ${currentTheme}, ${currentFontSize}px`);
 });
 }
 toggleTheme() {
 this.theme.update(t => t === 'light' ? 'dark' : 'light');
 }
}

No dependency array. No stale closures. The effect reads theme() and fontSize(), so it re-runs when either changes. Angular figures out the tracking automatically. If you have ever wasted an afternoon on a missing entry in React's useEffect dependency array, you know why this matters.

But here is the gotcha: cleanup. Effects are tied to their injection context's lifecycle, so they clean up when the component is destroyed. But what about cancelling a timer or tearing down a subscription mid-lifecycle? You need onCleanup:

effect-cleanup-example.ts
effect((onCleanup) => {
 const searchTerm = this.searchQuery();
 // Set up a debounced API callconst timeoutId = setTimeout(() => {
 this.searchService.search(searchTerm);
 }, 300);
 // Clean up the previous timeout if the signal changes againonCleanup(() => {
 clearTimeout(timeoutId);
 });
});

The cleanup runs right before each re-execution and once more when the effect is destroyed. Miss this and you get timers stacking up, duplicate event listeners, memory leaks that only show up after navigating between routes a few dozen times. Every time.

Signals in Components

input() replaces @Input() -- read-only signal, value from parent. model() creates two-way binding in one declaration instead of the old @Input() plus @Output() pair. output() emits events without EventEmitter.

All three working together:

profile-editor.component.ts
import {
 Component, input, output, model, computed, signal
} from'@angular/core';
@Component({
 selector: 'app-profile-editor',
 standalone: true,
 template: `
 <div class="profile-editor">
 <h3>Edit Profile for {{ userId() }}</h3>
 <label>
 Display Name
 <input
 [value]="displayName()"
 (input)="displayName.set($event.target.value)"
 />
 </label>
 <p>Characters remaining: {{ charsRemaining() }}</p>
 <label>
 <input
 type="checkbox"
 [checked]="isPublic()"
 (change)="isPublic.set($event.target.checked)"
 />
 Make profile public
 </label>
 <div class="actions">
 <button
 (click)="save.emit({ name: displayName(), public: isPublic() })"
 [disabled]="!isValid()"
 >
 Save Changes
 </button>
 </div>
 </div>
 `
})
export classProfileEditorComponent {
 // Read-only input signal from the parent
 userId = input.required<string>();
 // Two-way bindable model signal
 displayName = model('');
 isPublic = model(false);
 // Output signal for emitting events
 save = output<{ name: string; public: boolean }>();
 // Computed signals that react to model changes
 charsRemaining = computed(() => 50 - this.displayName().length);
 isValid = computed(() =>this.displayName().trim().length >= 2 &&
 this.displayName().length <= 50
 );
}

And the parent side:

parent-usage.html
<!-- In the parent component template -->
<app-profile-editor
 [userId]="currentUserId()"
 [(displayName)]="userName"
 [(isPublic)]="profileVisibility"
 (save)="handleSave($event)"
/>

No glue code. One declaration instead of the old @Input() value plus @Output() valueChange = new EventEmitter() dance.

Signals vs RxJS: When to Use Each

Most RxJS usage in Angular apps is wrong. Not "suboptimal." Wrong. A BehaviorSubject holding a single boolean is not reactive programming -- it is a variable with extra steps. A pipe(map(...)) chain that derives one string from one number is not a stream -- it is arithmetic wrapped in ceremony. Signals replace all of that, and the code gets shorter and easier to reason about.

RxJS still wins for actual async work. HTTP requests, WebSocket streams, debounced input, race conditions, retry logic. Observables are built for sequences of values over time. But I would bet that 60-70% of the RxJS in most Angular codebases handles synchronous state that never needed observables in the first place.

Angular gives you interop functions so you do not have to pick one or the other:

signal-rxjs-interop.ts
import { Component, signal, inject } from'@angular/core';
import { toSignal, toObservable } from'@angular/core/rxjs-interop';
import { HttpClient } from'@angular/common/http';
import { switchMap, debounceTime, distinctUntilChanged } from'rxjs';
@Component({
 selector: 'app-user-search',
 standalone: true,
 template: `
 <input
 [value]="query()"
 (input)="query.set($event.target.value)"
 placeholder="Search users..."
 />
 @if (results()) {
 @for (user of results(); track user.id) {
 <div class="user-card">{{ user.name }}</div>
 }
 } @else {
 <p>Loading...</p>
 }
 `
})
export classUserSearchComponent {
 private http = inject(HttpClient);
 // Source of truth is a signal
 query = signal('');
 // Convert signal to observable for async operationsprivate results$ = toObservable(this.query).pipe(
 debounceTime(300),
 distinctUntilChanged(),
 switchMap(q =>
 q.length > 1
 ? this.http.get<User[]>(`/api/users?search=${q}`)
 : of([])
 )
 );
 // Convert the observable result back to a signal for the template
 results = toSignal(this.results$, { initialValue: [] as User[] });
}

Signal for the query, observable pipeline for the async search logic, signal again for the template. Each tool handling what it is actually good at. Or you could do the whole thing in RxJS and manually unsubscribe in ngOnDestroy. Your call.

Start with a signal. If you need more than a couple of RxJS operators chained together, move that logic into an observable pipeline and convert the final result back with toSignal(). Most of what you delete during this kind of migration turns out to be subscription management boilerplate. The actual business logic barely changes.

Real-World State Management with Signals

NgRx is overkill for most applications. Controversial? Maybe. But I have seen teams spend weeks setting up actions, reducers, effects, and selectors for state that could have lived in a service with three signals and two methods. The ceremony-to-value ratio is brutal for anything under enterprise scale.

Private writable signals for state. .asReadonly() or computed signals for public access. Explicit methods for state transitions. Components read but never directly mutate.

todo-store.service.ts
import { Injectable, signal, computed } from'@angular/core';
interface Todo {
 id: number;
 title: string;
 completed: boolean;
 createdAt: Date;
}
type FilterType = 'all' | 'active' | 'completed';
@Injectable({ providedIn: 'root' })
export classTodoStore {
 // Private writable stateprivate todosState = signal<Todo[]>([]);
 private filterState = signal<FilterType>('all');
 private nextId = signal(1);
 // Public read-only access
 todos = this.todosState.asReadonly();
 filter = this.filterState.asReadonly();
 // Derived state with computed signals
 filteredTodos = computed(() => {
 const todos = this.todosState();
 const filter = this.filterState();
 switch (filter) {
 case'active':
 return todos.filter(t => !t.completed);
 case'completed':
 return todos.filter(t => t.completed);
 default:
 return todos;
 }
 });
 activeCount = computed(() =>this.todosState().filter(t => !t.completed).length
 );
 completedCount = computed(() =>this.todosState().filter(t => t.completed).length
 );
 allCompleted = computed(() =>this.todosState().length > 0 &&
 this.todosState().every(t => t.completed)
 );
 // State transition methodsaddTodo(title: string) {
 const id = this.nextId();
 this.nextId.update(n => n + 1);
 this.todosState.update(todos => [
 ...todos,
 { id, title: title.trim(), completed: false, createdAt: new Date() }
 ]);
 }
 toggleTodo(id: number) {
 this.todosState.update(todos =>
 todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
 );
 }
 removeTodo(id: number) {
 this.todosState.update(todos => todos.filter(t => t.id !== id));
 }
 setFilter(filter: FilterType) {
 this.filterState.set(filter);
 }
 clearCompleted() {
 this.todosState.update(todos => todos.filter(t => !t.completed));
 }
}

Components that need this state simply inject the service and read from its public signals. The component for the todo list might look like this:

Testing is trivially easy. Signals are synchronous, so you call a method and immediately assert the new state. No subscriptions, no async resolution, no fakeAsync wrappers. Just call and check.

This pattern scales further than people give it credit for. Predictable transitions, derived state that stays in sync, clean read/write separation. But keep state flat. Deeply nested objects need careful handling because Angular checks reference equality -- mutating a nested property without producing a new top-level reference will not trigger updates. This has bitten me more than once. And keep writable signals private. Expose only read-only projections so components cannot bypass your transition methods. If a component can call .set() directly on your store's internal state, your "store" is just a global variable with a fancy name.

Split stores by feature. A CartStore owns shopping cart items. A UserStore owns authentication and profile data. A UIStore owns sidebar state and notification preferences. Small, focused, testable in isolation. Components inject what they need.

For larger apps with genuinely complex cross-cutting concerns -- undo/redo, devtools integration, plugin architectures -- NgRx SignalStore builds on these same primitives. But reach for it when you actually need it, not because "state management" sounds like it requires a library.

Anurag Sinha

Anurag Sinha

Full Stack Developer & Technical Writer

Anurag is a full stack developer and technical writer. He covers web technologies, backend systems, and developer tools for the Codertronix community.