| |

Angular 46 🅰️ Advanced RxJS State Patterns

State management in Angular has moved through several generations. The first was services holding plain properties, mutated by components. The second was services holding BehaviorSubjects, with components subscribing to the observable streams. The third, in modern Angular, is signals — synchronous, dependency-tracking reactive values. But RxJS has not gone away: signals handle synchronous state, while RxJS handles asynchronous streams (HTTP, WebSockets, timers, route parameters). The advanced patterns are where the two meet — where signals drive RxJS, where RxJS feeds signals, and where the state is derived from both. This chapter covers the patterns for combining RxJS with signals, the store pattern built on scan and shareReplay, the view-model pattern built on combineLatest, the effect pattern for side effects, and the pitfalls that come from mixing the two models. It is the practical chapter that connects the RxJS material from Angular 39 through 45 to the signal-based world of modern Angular.

Key point: Signals and RxJS serve different roles: signals are for synchronous state (a counter, a form value, a selected filter) and RxJS is for asynchronous streams (HTTP, WebSocket, intervals, router events). The bridge between the two is toSignal (RxJS → signal) and toObservable (signal → RxJS). The store pattern uses a Subject of actions piped through scan to produce a state stream, which is shared with shareReplay. The view-model pattern uses combineLatest of the input streams, a switchMap to fetch, and a map to produce the shape the template needs. The effect function runs side effects when a signal changes, and it is the modern replacement for subscribing to a BehaviorSubject in the constructor.


The two models

Signals and RxJS overlap in purpose — both represent values over time — but they are designed for different problems. Knowing which to use for which is the first step.

Signals are synchronous. A signal has a current value, readable at any time with (). It tracks its dependencies and notifies the consumers when it changes. The notification is synchronous, and the value is always available. Signals are for state: a counter, a selected tab, a form value, a derived computation.

RxJS is asynchronous. An Observable is a stream that emits over time. It may emit zero, one, or many values, and it can be canceled. The values are not readable synchronously; the consumer must subscribe. RxJS is for streams: HTTP requests, WebSocket messages, intervals, router events, form value changes.

Why the two are not competitors. A signal cannot replace an HTTP request, because the request is asynchronous and produces a single value after a delay. An Observable cannot easily replace a signal that is read synchronously in a template, because the template would need the async pipe and the value would be a stream. The two are for different roles, and the modern Angular code uses both.

Why the two must interoperate. A component has both kinds of state — a signal for the selected filter and an Observable for the fetched items. The view model is derived from both. The interoperation is what makes the combination work, and toSignal and toObservable are the bridges.

Why signals are the modern default for state. A signal is read synchronously, which simplifies the template — no async pipe, no subscription. A computed signal derives from other signals, and it recomputes only when its dependencies change. The change detection integrates with signals, so a component that reads a signal is marked for check when the signal changes. The model is simpler than a BehaviorSubject and a subscription.

Why RxJS remains essential for streams. A stream is an asynchronous sequence, and RxJS has the operators for it — debounceTime, switchMap, catchError, retry, combineLatest. A signal has no equivalent for these. The HTTP client returns an Observable, and the operators are how the request is composed. The two models are complementary.

Why the choice is per-value. A single value can start as a signal (the filter), be converted to an Observable (for the request pipeline), and be converted back to a signal (for the template). The conversions are cheap, and the choice is about which model fits the value at each stage.

Why the two-model approach is the modern Angular answer. The Angular team’s guidance is to use signals for state and RxJS for events and asynchronous streams. The two are not in competition; they are the two halves of the reactive model. The patterns in this chapter are the ways the two halves connect.


The toSignal bridge

The toSignal function converts an Observable to a signal. It subscribes to the Observable and produces a signal whose value is the latest emission.

import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';

@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    @if (users(); as list) {
      @for (user of list; track user.id) {
        <div>{{ user.name }}</div>
      }
    }
  `,
})
export class UsersComponent {
  private readonly http = inject(HttpClient);

  readonly users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] },
  );
}

The users signal is initialized with an empty array and updated when the HTTP request completes. The template reads users() synchronously, and the async pipe is not needed.

Why the initialValue is required. A signal must have a value at all times, and an Observable may not have emitted yet. The initialValue is the value before the first emission. Without it, the signal is typed as T | undefined, and the template must handle undefined.

Why the subscription is managed. The toSignal function subscribes when the injection context is created and unsubscribes when the context is destroyed. The subscription is tied to the component’s lifecycle, and there is no manual unsubscribe in ngOnDestroy.

Why toSignal must be called in an injection context. The function uses inject() internally to get the DestroyRef, and it must be called during the component’s construction or in a field initializer. Calling it in a method or after the constructor is an error.

Why the signal is the modern replacement for async pipe. The async pipe subscribes in the template, renders the value, and unsubscribes on destroy. The toSignal does the same, but the value is a signal that can be read synchronously and combined with computed. The signal is more flexible and integrates with the rest of the reactive model.

Why the requireSync option matters. For an Observable that emits synchronously — a BehaviorSubject with an initial value, an of(1, 2, 3) — the requireSync: true option tells toSignal that the first value is available immediately. The signal has no undefined in its type, and the initialValue is not needed.

readonly count = toSignal(this.count$, { requireSync: true });
// count is Signal<number>, not Signal<number | undefined>

Why toSignal can lose values if used carelessly. The conversion subscribes to the Observable, and the values before the subscription are missed. A cold Observable that emits on subscription — an HTTP request, an interval — is fine. A hot Observable that emits before the subscription is a problem, and the BehaviorSubject or the ReplaySubject is the fix.


The toObservable bridge

The toObservable function converts a signal to an Observable. It produces an Observable that emits the signal’s value whenever the signal changes.

import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';

@Component({ selector: 'app-search', standalone: true, template: `` })
export class SearchComponent {
  readonly term = signal('');

  readonly results$ = toObservable(this.term).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
  );
}

The term signal is the input, and the toObservable converts it to a stream. The operators — debounceTime, distinctUntilChanged, switchMap — are applied to the stream, and the result is an Observable.

Why the signal is the source of truth. The term signal can be read synchronously, set by a template binding, and combined with computed. The Observable is the derived pipeline, and the signal is the state. The conversion is the bridge.

Why the Observable is converted back. The results$ is an Observable, and if the template reads it, the async pipe is needed. The toSignal converts it back, and the template reads a signal.

readonly results = toSignal(this.results$, { initialValue: [] });

The pipeline is: signal → Observable (with operators) → signal. The intermediate Observable is the pipeline, and the final signal is the template’s source.

Why the conversion is cheap. The toObservable produces an Observable that emits on the signal’s change. The emission is synchronous with the signal’s update, and the subscription is managed by the injection context. The conversion does not duplicate the state; it is a view.

Why the conversion has a subtle timing. The toObservable emits the current value on subscription, then the subsequent changes. The first emission is the signal’s value at subscription time. The operators that expect a delay — debounceTime — delay the first emission too, which may or may not be desired.

Why the conversion should be used sparingly. A signal that is only read in the template does not need to be converted. A signal that feeds an asynchronous pipeline is the case for the conversion. The conversion adds a layer, and the layer should earn its place.

Why the two bridges are the foundation of the hybrid pattern. The pattern is: signals for the state, Observables for the pipelines, and the bridges to connect them. The signal is the input to the pipeline, and the pipeline’s result is converted back. The rest of the chapter is the application of this pattern.


The store pattern

The store pattern holds the state in a stream and exposes it as an observable. It is the Redux-like pattern, and it is built on scan and shareReplay.

interface State {
  items: Item[];
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: 'load' }
  | { type: 'loadSuccess'; items: Item[] }
  | { type: 'loadError'; error: string };

const initialState: State = { items: [], loading: false, error: null };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'load':
      return { ...state, loading: true, error: null };
    case 'loadSuccess':
      return { ...state, items: action.items, loading: false };
    case 'loadError':
      return { ...state, loading: false, error: action.error };
  }
}

@Injectable({ providedIn: 'root' })
export class Store {
  private readonly actions$ = new Subject<Action>();

  readonly state$ = this.actions$.pipe(
    scan(reducer, initialState),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  readonly state = toSignal(this.state$, { requireSync: true });

  dispatch(action: Action): void {
    this.actions$.next(action);
  }
}

The store holds a Subject of actions. The scan accumulates the state, applying the reducer to each action. The shareReplay shares the latest state with all subscribers. The state signal is the same state, read synchronously.

Why scan is the reducer. The scan operator is the stream version of reduce. It applies the accumulator function to each value and emits the running result. The accumulator is the reducer, and the emitted values are the states.

Why shareReplay is required. Without it, each subscriber to state$ would subscribe to the actions$ Subject and re-run the scan from the beginning. The state would be duplicated, and each subscriber would have its own. The shareReplay shares the single scan and caches the latest state.

Why the state signal is the modern read. The toSignal with requireSync: true produces a signal with the current state. The template reads it synchronously, and the components that need the state use the signal. The state$ Observable remains for the components that need to combine it with other streams.

Why the actions are typed. The Action union is the set of possible actions. The reducer’s switch is exhaustive, and the compiler checks that every action is handled. Adding a new action requires adding a case, and the compiler reports the missing ones.

Why the state is immutable. The reducer returns a new state object, not a mutation. The immutable update is what makes the shareReplay and the signal’s change detection work — the reference changes, and the consumers see the change.

Why the store is a service. The store is providedIn: 'root', so it is a singleton. The components inject it and dispatch actions or read the state. The state is shared, and the actions are the only way to change it.

Why the store pattern is less common with signals. A signal-based store uses signals for the state and methods for the updates. The scan-based store remains useful when the actions are streams — from the DOM, from a WebSocket, from a timer — and the reducer is the natural way to handle them. The signal-based store is simpler for the cases where the updates are direct.

Why the store pattern is the Redux model. The actions are the messages, the reducer is the pure function, and the state is the accumulated result. The scan and shareReplay are the RxJS implementation. The pattern is the same as Redux’s, expressed in the RxJS vocabulary.


The view-model pattern

The view-model pattern combines the input streams into the shape the template needs. It is built on combineLatest, switchMap, and map.

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    @if (vm(); as vm) {
      <h1>{{ vm.user.name }}</h1>
      @for (item of vm.items; track item.id) {
        <div>{{ item.name }}</div>
      }
      @if (vm.loading) { <p>Loading...</p> }
    }
  `,
})
export class DashboardComponent {
  private readonly http = inject(HttpClient);
  private readonly user = signal<User | null>(null);
  private readonly filter = signal('');

  readonly vm = toSignal(
    combineLatest([
      toObservable(this.user),
      toObservable(this.filter).pipe(
        debounceTime(300),
        distinctUntilChanged(),
      ),
    ]).pipe(
      switchMap(([user, filter]) =>
        this.http.get<Item[]>(`/api/items?q=${filter}`).pipe(
          map((items) => ({ user, items, loading: false })),
          catchError(() => of({ user, items: [], loading: false })),
          startWith({ user, items: [], loading: true }),
        ),
      ),
    ),
    { initialValue: { user: null, items: [], loading: false } },
  );
}

The vm signal is the view model. The combineLatest of the user and filter streams produces a tuple whenever either changes. The switchMap cancels the previous request and starts a new one. The map produces the view model shape, and the catchError recovers from failures. The startWith provides the loading state before the request completes.

Why combineLatest is the combiner. The view model depends on several inputs, and the view updates when any of them changes. The combineLatest is the expression of that dependency.

Why switchMap is the fetcher. The request depends on the inputs, and the previous request should be canceled when the inputs change. The switchMap is the cancellation, and the pattern is the search pattern from Angular 41.

Why startWith provides the loading state. The startWith emits a value before the request completes, which is the loading state. The template renders the loading indicator until the request completes and the map emits the loaded state.

Why catchError is inside the inner pipe. The inner catchError recovers the individual request without terminating the outer combineLatest. A failure of one request does not stop the pipeline, and the next input change triggers a new request.

Why toSignal is the final step. The view model is a signal, and the template reads it synchronously. The async pipe is not needed, and the @if (vm(); as vm) pattern is the modern template syntax.

Why the view model is a single object. The template reads vm.user, vm.items, and vm.loading from one object. The alternative — separate signals for each piece — requires the template to read each one, and the object is the shape the template needs. The single object is the view model.

Why the pattern scales. The pattern works for two inputs and for ten. Each input is a signal or a stream, and the combineLatest combines them. The switchMap fetches, and the map produces the shape. The pattern is the same regardless of the number of inputs.

Why the view-model pattern is the standard for a component with async data. A component that fetches data based on inputs needs the combination of the inputs, the cancellation of the previous request, the loading state, and the error handling. The pattern provides all of them, and it is the same shape for every component. The pattern is the answer to the question “how do I structure a component that fetches data?”.


The effect pattern

The effect function runs a side effect when a signal changes. It is the modern replacement for subscribing to a BehaviorSubject in the constructor.

import { effect, inject } from '@angular/core';

@Component({ selector: 'app-theme', standalone: true, template: `` })
export class ThemeComponent {
  private readonly theme = inject(ThemeService);
  private readonly document = inject(DOCUMENT);

  constructor() {
    effect(() => {
      const current = this.theme.current();
      this.document.body.classList.toggle('dark', current === 'dark');
    });
  }
}

The effect runs when the theme.current signal changes, and it updates the DOM. The effect tracks the signals it reads, and it re-runs when any of them changes.

Why the effect is the side-effect mechanism. A signal is a value, and a computed derives a new value. A side effect — updating the DOM, logging, calling an external API — is not a value, and the effect is the mechanism for it.

Why the effect tracks the signals it reads. The effect’s function reads the signals, and the reads are recorded. When any of the recorded signals changes, the effect re-runs. The tracking is automatic, and the effect’s dependencies are the signals it reads.

Why the effect is not for the state. An effect is for the side effect, not the state. The state is in the signals, and the derived values are in the computed. The effect is the last step — the DOM update, the log, the external call — and it should not be the place where the state is computed.

Why the effect should be used sparingly. An effect that writes to a signal can create a loop — the signal change triggers the effect, which writes to the signal. The Angular team’s guidance is to use computed for the derived values and effect only for the side effects. The effect is the escape hatch, not the default.

Why the effect’s timing is after the change. The effect runs after the signal’s change is committed, which is after the change detection. The timing matters for the DOM updates and the external calls, which should happen after the state is settled.

Why the effect is the replacement for the BehaviorSubject subscription. The old pattern was to inject a service, subscribe to its BehaviorSubject in the constructor, and update the component’s state in the subscription. The modern pattern is to read the signal in a computed or an effect. The subscription is gone, and the signal is read.

Why the effect should not fetch data. An effect that fetches data on a signal change is a common mistake. The fetch should be in a switchMap of the signal’s stream, not in an effect. The effect is for the side effects that are not part of the data flow.


Common pitfalls

Mixing signals and RxJS has pitfalls, and each is worth knowing.

Converting a signal to an Observable and back. The conversion is cheap, but it should earn its place. A signal that is read in the template does not need to be converted. A signal that feeds a pipeline is the case for the conversion.

The toSignal with requireSync on a cold Observable. A cold Observable that does not emit synchronously — an HTTP request, an interval — does not have a value at subscription time. The requireSync: true assumes it does, and the result is a signal with undefined in its type or a runtime error. The initialValue is the fix.

The toObservable timing. The toObservable emits the current value on subscription. If the pipeline uses debounceTime, the first value is delayed, which may or may not be intended. The startWith can provide a synchronous first value when the delay is not wanted for the first emission.

The effect that writes to a signal. An effect that writes to a signal can create a loop. The computed is the tool for the derived values, and the effect is for the side effects. If the effect must write to a signal, the write should be to a different signal, and the loop should be broken.

The subscription leak. A subscription to an Observable that is not converted with toSignal and not cleaned up with takeUntilDestroyed is a leak. The toSignal manages the subscription, and the manual subscription needs the cleanup.

The combineLatest without startWith. The combineLatest waits for all sources to emit. A source that has not emitted blocks the combined stream. The startWith provides the initial value, and the combined stream emits promptly.

The shareReplay without refCount. The shareReplay(1) without refCount caches the value forever and never resets. The shareReplay({ bufferSize: 1, refCount: true }) is the modern form, and it resets when all subscribers leave.

Why the pitfalls are about the boundary. The pitfalls are where the two models meet — the conversion, the timing, the subscription management. Each model is well-understood on its own; the combination is where the surprises are. Knowing the boundary is the skill.

Why the two-model approach is stable. The signals and RxJS are not a transitional state; they are the two halves of the reactive model. The signals are the state, and the RxJS is the streams. The two will coexist, and the patterns in this chapter are the stable answer.


Complete Example Session

import { Component, Injectable, inject, signal, computed, effect } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Subject, of, combineLatest } from 'rxjs';
import {
  scan, shareReplay, switchMap, debounceTime, distinctUntilChanged,
  catchError, startWith, map,
} from 'rxjs/operators';

// ============================================
// PART 1: THE STORE
// ============================================

interface State {
  items: Item[];
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: 'load' }
  | { type: 'success'; items: Item[] }
  | { type: 'error'; error: string };

const initialState: State = { items: [], loading: false, error: null };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'load':
      return { ...state, loading: true, error: null };
    case 'success':
      return { ...state, items: action.items, loading: false };
    case 'error':
      return { ...state, loading: false, error: action.error };
  }
}

@Injectable({ providedIn: 'root' })
export class ItemStore {
  private readonly http = inject(HttpClient);
  private readonly actions$ = new Subject<Action>();

  readonly state$ = this.actions$.pipe(
    scan(reducer, initialState),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  readonly state = toSignal(this.state$, { requireSync: true });

  load(): void {
    this.actions$.next({ type: 'load' });
    this.http.get<Item[]>('/api/items').subscribe({
      next: (items) => this.actions$.next({ type: 'success', items }),
      error: (error: HttpErrorResponse) =>
        this.actions$.next({ type: 'error', error: error.message }),
    });
  }
}

// ============================================
// PART 2: THE VIEW MODEL
// ============================================

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    @if (vm(); as vm) {
      <h1>{{ vm.user?.name }}</h1>
      @for (item of vm.items; track item.id) {
        <div>{{ item.name }}</div>
      }
      @if (vm.loading) { <p>Loading...</p> }
      @if (vm.error) { <p>{{ vm.error }}</p> }
    }
  `,
})
export class DashboardComponent {
  private readonly http = inject(HttpClient);
  private readonly user = signal<User | null>(null);
  private readonly filter = signal('');

  readonly vm = toSignal(
    combineLatest([
      toObservable(this.user),
      toObservable(this.filter).pipe(
        debounceTime(300),
        distinctUntilChanged(),
        startWith(''),
      ),
    ]).pipe(
      switchMap(([user, filter]) =>
        this.http.get<Item[]>(`/api/items?q=${filter}`).pipe(
          map((items) => ({ user, items, loading: false, error: null })),
          catchError(() => of({ user, items: [], loading: false, error: 'Failed' })),
          startWith({ user, items: [], loading: true, error: null }),
        ),
      ),
    ),
    { initialValue: { user: null, items: [], loading: false, error: null } },
  );
}

// ============================================
// PART 3: THE EFFECT
// ============================================

@Component({ selector: 'app-theme', standalone: true, template: `` })
export class ThemeComponent {
  private readonly theme = inject(ThemeService);

  constructor() {
    effect(() => {
      document.body.classList.toggle('dark', this.theme.current() === 'dark');
    });
  }
}

// ============================================
// PART 4: THE COMPUTED
// ============================================

@Component({ selector: 'app-cart', standalone: true, template: `` })
export class CartComponent {
  private readonly cart = inject(CartService);
  readonly items = this.cart.items;

  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  readonly count = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0),
  );
}

// ============================================
// PART 5: THE SIGNAL DRIVING THE PIPELINE
// ============================================

@Component({ selector: 'app-search', standalone: true, template: `` })
export class SearchComponent {
  private readonly http = inject(HttpClient);
  readonly term = signal('');

  readonly results = toSignal(
    toObservable(this.term).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
      catchError(() => of([])),
    ),
    { initialValue: [] },
  );
}

// ============================================
// PART 6: THE OBSERVABLE DRIVING THE SIGNAL
// ============================================

@Component({ selector: 'app-clock', standalone: true, template: `{{ time() }}` })
export class ClockComponent {
  private readonly time$ = interval(1000).pipe(
    map(() => new Date().toLocaleTimeString()),
  );

  readonly time = toSignal(this.time$, { initialValue: '' });
}

// ============================================
// PART 7: COMBINING SIGNALS AND STREAMS
// ============================================

@Component({ selector: 'app-mixed', standalone: true, template: `` })
export class MixedComponent {
  private readonly http = inject(HttpClient);
  private readonly userId = signal('1');
  private readonly refresh$ = new Subject<void>();

  readonly data = toSignal(
    combineLatest([
      toObservable(this.userId),
      this.refresh$.pipe(startWith(void 0)),
    ]).pipe(
      switchMap(([id]) => this.http.get<User>(`/api/users/${id}`)),
      catchError(() => of(null)),
    ),
    { initialValue: null },
  );

  refresh(): void {
    this.refresh$.next();
  }
}

// ============================================
// PART 8: THE EFFECT FOR PERSISTENCE
// ============================================

@Component({ selector: 'app-settings', standalone: true, template: `` })
export class SettingsComponent {
  private readonly settings = inject(SettingsService);

  constructor() {
    effect(() => {
      const current = this.settings.current();
      localStorage.setItem('settings', JSON.stringify(current));
    });
  }
}

// ============================================
// PART 9: THE PITFALLS
// ============================================

// toSignal without initialValue on an async source:
// readonly users = toSignal(this.http.get<User[]>('/api/users'));
// users is Signal<User[] | undefined>

// toObservable with debounceTime delays the first value:
// toObservable(this.term).pipe(debounceTime(300))
// the first emission is delayed by 300ms

// An effect that writes to a signal:
// effect(() => { this.count.set(this.count() + 1); });  // loop

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't subscribe manually when toSignal works
// this.http.get('/api').subscribe(...)  // use toSignal

// Don't use an effect for data fetching
// effect(() => { this.http.get('/api').subscribe(); });  // use switchMap

// Don't forget the initialValue
// The signal has undefined without it.

// Don't convert a signal to an Observable without a reason
// The template can read the signal directly.

// Don't mix the models without the bridges
// Use toSignal and toObservable.

The ten parts cover the store, the view model, the effect, the computed, the signal-driven pipeline, the observable-driven signal, the combination, the persistence effect, the pitfalls, and the anti-patterns.


Quick Reference

The Bridges

FunctionDirectionPurpose
toSignal(obs$)RxJS → SignalRead an Observable synchronously
toObservable(sig)Signal → RxJSApply operators to a signal
toSignal(obs$, { initialValue })RxJS → SignalWith an initial value
toSignal(obs$, { requireSync: true })RxJS → SignalFor a synchronous source

The Store Pattern

PartPurpose
Subject<Action>The action stream
scan(reducer, initial)The state accumulator
shareReplay({ bufferSize: 1, refCount: true })Share the state
toSignal(state$, { requireSync: true })The state signal
dispatch(action)The write

The View-Model Pattern

PartPurpose
combineLatest([...])Combine the inputs
switchMap(fetch)Fetch on input change
map(shape)Produce the view model
catchError(recover)Recover from errors
startWith(loading)Provide the loading state
toSignal(vm$, { initialValue })The view-model signal

The Effect

AspectValue
PurposeSide effect on signal change
TrackingAutomatic (the signals read)
TimingAfter change detection
UseDOM updates, logs, external calls
Not forData fetching, state derivation

The Pitfalls

PitfallSolution
toSignal without initialValueAdd it
requireSync on a cold sourceUse initialValue
Effect writes to a signalUse computed
Effect fetches dataUse switchMap
shareReplay(1) without refCountUse the config object
Missing cleanupUse toSignal or takeUntilDestroyed

Best Practices

✅ Do This:

// Use toSignal for a template value
readonly users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] }); // ✅

// Use requireSync for a synchronous source
readonly count = toSignal(this.count$, { requireSync: true }); // ✅

// Use toObservable to apply operators
toObservable(this.term).pipe(debounceTime(300), switchMap(...)) // ✅

// Use combineLatest for the view model
combineLatest([toObservable(a), toObservable(b)]).pipe(switchMap(...)) // ✅

// Use startWith for the loading state
startWith({ loading: true, items: [] })                        // ✅

// Use catchError inside the inner pipe
switchMap(() => http.get(...).pipe(catchError(() => of([]))))  // ✅

// Use shareReplay with refCount
shareReplay({ bufferSize: 1, refCount: true })                 // ✅

// Use effect for side effects
effect(() => document.body.classList.toggle('dark', this.theme() === 'dark')); // ✅

❌ Don’t Do This:

// Don't subscribe manually when toSignal works
this.http.get('/api').subscribe((data) => this.data.set(data)); // ⚠️

// Don't use an effect for data fetching
effect(() => { this.http.get('/api').subscribe(); });           // ⚠️

// Don't forget the initialValue
toSignal(this.http.get('/api'))  // Signal<T | undefined>       // ⚠️

// Don't use requireSync on a cold source
toSignal(this.http.get('/api'), { requireSync: true });         // ⚠️

// Don't write to a signal in an effect
effect(() => { this.count.set(this.count() + 1); });            // ⚠️

// Don't use shareReplay(1) without refCount
shareReplay(1)  // cache never resets                          // ⚠️

// Don't convert a signal for no reason
toObservable(this.staticValue)  // unnecessary                  // ⚠️

Common Pitfalls

PitfallProblemSolution
toSignal no initialValueundefined in typeAdd the initial
requireSync on cold sourceRuntime errorUse initialValue
Effect writes to signalInfinite loopUse computed
Effect fetches dataWrong toolUse switchMap
shareReplay(1) no refCountStale cacheConfig object
combineLatest without startWithNo emissionAdd startWith
catchError outside inner pipeTerminates outerPlace inside
Manual subscriptionLeaktoSignal or takeUntilDestroyed

Real-World Examples

1. HTTP to signal

readonly users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });

2. Signal to Observable

toObservable(this.term).pipe(debounceTime(300), switchMap(search))

3. View model

toSignal(combineLatest([...]).pipe(switchMap(fetch), map(shape)), { initialValue })

4. Store

readonly state$ = this.actions$.pipe(scan(reducer, initial), shareReplay({ bufferSize: 1, refCount: true }));

5. Store signal

readonly state = toSignal(this.state$, { requireSync: true });

6. Effect

effect(() => document.body.classList.toggle('dark', this.theme() === 'dark'));

7. Computed

readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));

8. Signal plus refresh

combineLatest([toObservable(this.userId), this.refresh$.pipe(startWith(void 0))])

9. Loading state

startWith({ loading: true, items: [] })

10. Error recovery

switchMap(() => http.get(...).pipe(catchError(() => of([]))))

Visual: Signals and RxJS

┌──────────────────────────────────────────────────────────┐
│  SIGNALS                                                 │
│    Synchronous. Read with ().                            │
│    Track dependencies.                                   │
│    State: counters, filters, form values.                │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  RXJS                                                    │
│    Asynchronous. Subscribe.                              │
│    Operators: debounceTime, switchMap, catchError.       │
│    Streams: HTTP, WebSocket, intervals, route params.    │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  THE BRIDGES                                             │
│    toSignal(obs$)      → RxJS to Signal                  │
│    toObservable(sig)   → Signal to RxJS                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The View-Model Pattern

┌──────────────────────────────────────────────────────────┐
│  SIGNAL: user                                            │
│  SIGNAL: filter                                          │
│       │                                                  │
│       │  toObservable                                    │
│       ▼                                                  │
│  STREAM: user$                                           │
│  STREAM: filter$.pipe(debounceTime, distinctUntilChanged)│
│       │                                                  │
│       ▼                                                  │
│  combineLatest([user$, filter$])                         │
│       │                                                  │
│       ▼                                                  │
│  switchMap(([user, filter]) =>                           │
│    http.get(...).pipe(                                   │
│      map(shape),                                         │
│      catchError(recover),                                │
│      startWith(loading),                                 │
│    ),                                                    │
│  )                                                       │
│       │                                                  │
│       ▼                                                  │
│  toSignal(vm$, { initialValue })                         │
│       │                                                  │
│       ▼                                                  │
│  SIGNAL: vm                                              │
│       │                                                  │
│       ▼                                                  │
│  TEMPLATE: vm()                                          │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Store Pattern

┌──────────────────────────────────────────────────────────┐
│  dispatch(action)                                        │
│       │                                                  │
│       ▼                                                  │
│  actions$: Subject<Action>                               │
│       │                                                  │
│       ▼                                                  │
│  scan(reducer, initialState)                             │
│       │  accumulates the state                           │
│       ▼                                                  │
│  state$: Observable<State>                               │
│       │                                                  │
│       ├──► shareReplay({ bufferSize: 1, refCount: true })│
│       │                                                  │
│       ├──► toSignal(state$, { requireSync: true })       │
│       │      → the state signal                          │
│       │                                                  │
│       └──► subscribers                                   │
│                                                          │
│  One dispatch, one state, many readers.                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Bridges

┌──────────────────────────────────────────────────────────┐
│  RxJS → SIGNAL (toSignal)                                │
│                                                          │
│  obs$ ──► toSignal ──► signal                            │
│    │                     │                               │
│    │  subscribes         │  readable with ()             │
│    │  on create          │  updates on emission          │
│    │                     │                               │
│    └── unsubscribes on destroy                            │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  SIGNAL → RXJS (toObservable)                            │
│                                                          │
│  signal ──► toObservable ──► obs$                        │
│    │                          │                          │
│    │  current value           │  emits on change         │
│    │  on subscription         │  operators apply         │
│    │                          │                          │
│    └── the source of truth                                │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Effect

┌──────────────────────────────────────────────────────────┐
│  effect(() => {                                          │
│    const current = this.theme.current();                 │
│    document.body.classList.toggle('dark', current === 'dark');│
│  });                                                     │
│                                                          │
│  The effect reads this.theme.current().                  │
│  The read is recorded.                                   │
│                                                          │
│  When the theme changes:                                 │
│    │                                                     │
│    ▼                                                     │
│  The effect re-runs.                                     │
│  The DOM is updated.                                     │
│                                                          │
│  The effect is for side effects.                         │
│  The state is in the signals.                            │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Decision

┌──────────────────────────────────────────────────────────┐
│  Is the value synchronous state?                         │
│       │                                                  │
│       └── Yes ──► Signal                                 │
│              - Read with ()                              │
│              - computed for derived                      │
│              - effect for side effects                   │
│                                                          │
│  Is the value an asynchronous stream?                    │
│       │                                                  │
│       └── Yes ──► RxJS                                   │
│              - HTTP, WebSocket, intervals                │
│              - operators for transformation              │
│              - toSignal for the template                 │
│                                                          │
│  Does the state drive the stream?                        │
│       │                                                  │
│       └── Yes ──► toObservable, then operate, then toSignal│
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

ConceptModel
StateSignal
StreamRxJS Observable
Derived statecomputed
Side effecteffect
RxJS → SignaltoSignal
Signal → RxJStoObservable
StoreSubject + scan + shareReplay
View modelcombineLatest + switchMap + map
PatternTools
Storescan, shareReplay, toSignal
View modelcombineLatest, switchMap, startWith, toSignal
Effecteffect
Signal pipelinetoObservable, operators, toSignal

Key takeaways:

  • Signals are for synchronous state and RxJS is for asynchronous streams — the two are complementary, not competitors
  • The toSignal bridge converts an Observable to a signal — it subscribes on creation, unsubscribes on destroy, and requires an initialValue for an async source
  • The toObservable bridge converts a signal to an Observable — it emits the signal’s value on change, and the operators apply to the stream
  • The store pattern uses Subject + scan + shareReplay — the actions are the stream, the reducer is the accumulator, and the state is shared
  • The view-model pattern uses combineLatest + switchMap + map — the inputs are combined, the request is canceled on change, and the shape is produced
  • The startWith provides the loading state — the value is emitted before the request completes, and the template renders the loading indicator
  • The catchError inside the inner pipe recovers per request — the outer pipeline continues for subsequent input changes
  • The effect is for side effects — it tracks the signals it reads and re-runs when they change, and it is not for data fetching or state derivation
  • The computed is for derived state — it recomputes only when its dependencies change, and it is the tool for the derived values
  • The shareReplay with refCount: true is the modern form — the cache resets when all subscribers leave, and the stale value is not returned

Remember: The modern Angular application uses both signals and RxJS. The signals are the state, the RxJS is the streams, and the bridges connect them. The store pattern holds the state in a scan of actions, the view-model pattern combines the inputs and fetches the data, and the effect handles the side effects. The two models are the two halves of the reactive model, and knowing which to use for which value — and how to convert between them — is the skill that makes the application predictable.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!