Modern Angular
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 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.
@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.
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.
@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.
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;
});
}@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
toSignalat component reads, not as a reason to delete every Observable. - Reach for
resourceandhttpResourceon reads now that they are stable in v22; keep mutations onHttpClient.
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
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.
André Ramosavailable for remote roles, UTC−3Get in touch →