HTTP State Without Turning Everything Into Signals

HTTP state gets easier when the team separates UI values, derived values, remote reads, and async workflows before choosing Signals or RxJS. HttpClient stays canonical for production, and httpResource is now stable as of Angular 22, so the question moved from 'is it safe' to 'where does it fit.' toSignal is still the bridge for an existing RxJS stream.

Classify the state before choosing the tool

The first review question is not "Signals or RxJS?" It is "what kind of state is this feature carrying?"

A filter string, a derived button state, an HTTP read, a reload action, a WebSocket stream, and shared domain state are different problems. When the same tool handles all of them, the result is usually a facade full of adapters that nobody wants to debug.

State shapeExampleStarting boundary
Local UI valueselected tab, expanded row, typed filtersignal
Synchronous derivationcan submit, filtered count, display labelcomputed
Simple remote readprofile by id, settings pageHttpClient + AsyncPipe or toSignal
Async workflow over timesearch, retry, polling, cancellationRxJS in a facade or store boundary
Shared domain statecart, auth, permissions, multi-screen workflowExisting store/facade, Signals at component reads

Keep the API service plain

Preserve the plain API service first. It should describe the backend contract, use Angular's HttpClient, and avoid deciding how every component consumes state.

That service is not old-fashioned just because it returns an Observable. It is a stable contract. The modernization usually belongs at the component or facade boundary, where the UI needs a current value, loading state, retry, or cancellation.

Plain API servicets
@Injectable({ providedIn: 'root' })
export class OrdersApi {
  private readonly http = inject(HttpClient);

  listOrders() {
    return this.http.get<Order[]>('/api/orders');
  }
}

A facade that mixes tools on purpose

A feature facade can let each tool do one job. A local signal records the reload intent. RxJS models the request, loading, error, and replay behavior. toSignal gives the component a current state to read without manual subscription code.

Do not hide the RxJS stream as if it were legacy debt. It is the right place for request lifecycle, fallback state, and cache semantics. Signals improve the template-facing boundary.

The initial refresh value drives the first request, and reload() increments that value later. The inner stream owns loading state for each request instead of relying on a stray startWith before switchMap.

If the product should keep stale rows visible while a reload is in flight, encode that in OrdersState; do not leave the UI to infer it from shareReplay.

Orders facade boundaryts
type OrdersState =
  | { status: 'loading'; data: Order[]; error: null }
  | { status: 'ready'; data: Order[]; error: null }
  | { status: 'empty'; data: Order[]; error: null }
  | { status: 'error'; data: Order[]; error: string };

const loadingState: OrdersState = {
  status: 'loading',
  data: [],
  error: null,
};

@Injectable()
export class OrdersFacade {
  private readonly api = inject(OrdersApi);
  private readonly refresh = signal(0);

  readonly state$ = toObservable(this.refresh).pipe(
    switchMap(() =>
      this.api.listOrders().pipe(
        map((data) => ({
          status: data.length > 0 ? 'ready' : 'empty',
          data,
          error: null,
        }) satisfies OrdersState),
        startWith(loadingState),
        catchError(() => of({
          status: 'error',
          data: [],
          error: 'Failed to load orders',
        } satisfies OrdersState))
      )
    ),
    shareReplay({ bufferSize: 1, refCount: true })
  );

  readonly state = toSignal(this.state$, {
    initialValue: loadingState,
  });

  readonly orders = computed(() => this.state().data);
  readonly isLoading = computed(() => this.state().status === 'loading');
  readonly error = computed(() => this.state().error);

  reload() {
    this.refresh.update((value) => value + 1);
  }
}

The template should keep every state visible

Weak examples skip the states that hurt in production: initial loading, empty result, reload error, and retry. A few extra template branches are cheaper than a demo that only works on the happy path.

The component does not need to know whether the facade used RxJS, a store, or toSignal internally. It needs a clear state contract and stable list identity.

The template names the current state with @let so each branch reads from one discriminated value instead of repeating signal calls and non-null assertions.

Stateful template boundaryhtml
@let state = facade.state();

@switch (state.status) {
  @case ('loading') {
    <app-orders-skeleton />
  }
  @case ('error') {
    <app-inline-error
      [message]="state.error"
      (retry)="facade.reload()"
    />
  }
  @case ('empty') {
    <app-empty-orders />
  }
  @case ('ready') {
    @for (order of state.data; track order.id) {
      <app-order-row [order]="order" />
    }
  }
}

Where `httpResource` fits now that it is stable

httpResource fits a narrow read path because it packages value, loading, error, reload, and cancellation around reactive dependencies.

Angular 22 made it stable, so this is no longer about risk, it is about fit. It starts requests eagerly when dependencies allow it, cancels pending requests when dependencies change, and is built for reads. It should not become your default for mutations such as POST or PUT, which still belong on HttpClient.

Low-risk httpResource readts
export class CustomerPanelComponent {
  readonly customerId = input.required<string>();

  readonly customer = httpResource(() =>
    `/api/customers/${this.customerId()}`
  );

  readonly displayName = computed(() => {
    if (!this.customer.hasValue()) return 'Customer not loaded';
    return this.customer.value().name;
  });
}
Guard value reads before renderinghtml
@if (customer.hasValue()) {
  <app-customer-card [customer]="customer.value()" />
} @else if (customer.error()) {
  <app-inline-error
    message="Could not load customer"
    (retry)="customer.reload()"
  />
} @else if (customer.isLoading()) {
  <app-customer-skeleton />
}

Production rule for mature apps

For mature Angular apps, the production rule is steady: keep API services plain, keep RxJS where time and cancellation matter, expose Signals where the component needs current state, and reach for the now-stable resource APIs on reads where they earn their place.

That gives a team modern Angular without turning HTTP state into another rewrite campaign.

Reusable artifact

HTTP state review checklist

  • Name the state shape before choosing Signals, RxJS, store, or resource.
  • Keep API services focused on backend contracts.
  • Preserve loading, empty, error, retry, and cache semantics.
  • Decide whether reload clears stale data or keeps it visible, then encode that in the state contract.
  • Use toSignal at component reads, not as a reason to delete every Observable.
  • Reach for resource and httpResource on reads now that they are stable in v22; keep mutations on HttpClient.

Sources checked

Modern Angular Playbook

This article is one play.

The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.

You get both PDFs by email, through the Dojo IA list.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 7 min

Signals Without the RxJS War

A production boundary for Signals, computed values, RxJS streams, and interop, with no cosmetic rewrite of architecture that already works.

Read article →

Error · 7 min

NG0950: Reading a Required Input Before Angular Sets It

Why Angular's NG0950 fires when a required signal input is read before binding, when inputs actually become available, and the three fixes: read it in ngOnInit / computed / effect, give it a default, or bind it in the parent.

Read article →

Guide · 10 min

The Code That Breaks When Angular Goes Zoneless

A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike.

Read article →