---
title: "HTTP State Without Turning Everything Into Signals"
description: "A code-heavy guide to Angular HTTP state boundaries with HttpClient, RxJS, toSignal, and the now-stable httpResource."
deck: "HTTP state gets easier when the team separates UI values, derived values, remote reads, and async workflows before choosing Signals or RxJS. [HttpClient](https://angular.dev/guide/http) stays canonical for production, and [`httpResource`](https://angular.dev/api/common/http/httpResource) is now [stable as of Angular 22](https://angular.dev/events/v22), so the question moved from 'is it safe' to 'where does it fit.' [`toSignal`](https://angular.dev/api/core/rxjs-interop/toSignal) is still the bridge for an existing RxJS stream."
author: "André Ramos"
url: "https://andreramos.dev/angular/http-state-without-signals-everywhere/"
lang: "en"
type: "article"
---

# 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](https://angular.dev/guide/http) stays canonical for production, and [`httpResource`](https://angular.dev/api/common/http/httpResource) is now [stable as of Angular 22](https://angular.dev/events/v22), so the question moved from 'is it safe' to 'where does it fit.' [`toSignal`](https://angular.dev/api/core/rxjs-interop/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 shape | Example | Starting boundary |
|---|---|---|
| Local UI value | selected tab, expanded row, typed filter | `signal` |
| Synchronous derivation | can submit, filtered count, display label | `computed` |
| Simple remote read | profile by id, settings page | `HttpClient` + `AsyncPipe` or `toSignal` |
| Async workflow over time | search, retry, polling, cancellation | RxJS in a facade or store boundary |
| Shared domain state | cart, auth, permissions, multi-screen workflow | Existing 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 service*

```ts
@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 boundary*

```ts
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 boundary*

```html
@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 read*

```ts
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 rendering*

```html
@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

- https://angular.dev/guide/http
- https://angular.dev/api/common/http/httpResource
- https://angular.dev/guide/signals/resource
- https://angular.dev/api/core/rxjs-interop/toSignal
- https://angular.dev/api/core/rxjs-interop/toObservable
- https://angular.dev/guide/signals
- https://angular.dev/roadmap
- https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301

## Related

- [Signals Without the RxJS War](https://andreramos.dev/angular/signals-without-rxjs-war/)
- [NG0950: Reading a Required Input Before Angular Sets It](https://andreramos.dev/angular/angular-ng0950-required-input-not-set/)
- [The Code That Breaks When Angular Goes Zoneless](https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/)
