Managing Server State: resource, httpResource, chain() and Invalidation After Mutations
In Angular 22, server state is managed with the resource family, which carries the loading, error, reloading and local statuses itself: read with httpResource, write with HttpClient, and refresh the resource with reload() after a mutation. On Bookstore's book page we show with real output dependent resources with chain(), an optimistic review with rollback, two components fetching the same list, NgRx 22's withPreviousValueOnLoading and what reading value() in error does.
Version note: The examples in this post were run with Angular 22.2, NgRx 22.0.1 (@ngrx/signals), TypeScript 6.0 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0); browser behaviour was observed with Playwright 1.65 in headless Chromium against the mock Bookstore API in the snapshot. The parts about SSR and TanStack Query were not run; they are based on the documentation.
Predict First
We added a book details page to bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. On that page the book's reviews live in an httpResource, and the user can add a new review. The method that adds a review puts it in the list without waiting for the server's answer:
async addReview(): Promise<void> {
const draft = { author: 'Ada', rating: 5, text: this.text() };
const before = this.reviews.value();
this.saving.set(true);
this.saveError.set(null);
// Optimistic update: show the review now; the resource's status becomes 'local'
this.reviews.update((list) => [...list, { ...draft, id: -1, isbn: this.isbn() }]);
try {
await firstValueFrom(this.#http.post<Review>(`${apiUrl}/books/${this.isbn()}/reviews`, draft));
this.text.set('');
// The server is the source of truth again: fetch the list with the real id
this.reviews.reload();
} catch {
// Roll back to the list we had before the optimistic update
this.reviews.set(before);
this.saveError.set('Could not save your review. Please try again.');
} finally {
this.saving.set(false);
}
}
Dune has two reviews. The user types "Read it twice." and sends it, but the server answers this POST with a 503. How many reviews does the list show right after the button is pressed? How many after the error arrives? Which values does the resource's status() go through: loading, reloading, local, error? Write down your prediction; the real output is in the "After a Mutation" section.
In the first post of the series we defined server state as "data owned by the API, whose copy in the client goes stale". This post covers managing that data with the resource family, stable in Angular 22: statuses, hasValue(), a parameterised httpResource, chain(), optimistic updates and rolling them back, reload(), the same data being fetched twice by two components, and NgRx 22's resource extensions. resource and httpResource are introduced in the Angular series posts "Angular 19: Standalone by Default, linkedSignal and Data Loading with resource()" and "Angular 20: Stable Signal APIs, httpResource and the New File Naming"; here we look at their use in an architecture.
The Problems Specific to Server State
Client state such as the cart has a single correct value, and that value is in memory. With server state, the value in memory is always a copy, and the copy's lifecycle brings five questions you don't face with client state:
- What is shown while it loads? The first load and "I have old data and am waiting for new data" are different things.
- What is shown if the request fails, and does the rest of the page keep working?
- When did the copy go stale? Stock changes when another customer places an order; the global store example in the first post missed exactly that.
- If two components want the same data, do two requests go out?
- When the user changes the data (a mutation), when and how is the copy on screen updated?
You can solve these by hand in a store, but each one means a separate field and a separate rule: isLoading, error, lastFetchedAt, cancelling requests... Angular's resource family models the first three directly, and gives building blocks for the last two.
Reading with httpResource: Status and hasValue
The book details page's resources:
export class BookDetailsPage {
readonly #http = inject(HttpClient);
// bound from the :isbn route parameter by withComponentInputBinding()
readonly isbn = input.required<string>();
protected readonly book = httpResource<Book>(() => `${apiUrl}/books/${this.isbn()}`);
protected readonly reviews = httpResource<Review[]>(() => `${apiUrl}/books/${this.isbn()}/reviews`, {
defaultValue: [],
});
// Depends on the book's category: waits for the book, follows its loading and error status
protected readonly related = httpResource<BookPage>(({ chain }) => {
const category = chain(this.book)?.category;
return category ? { url: `${apiUrl}/books`, params: { category } } : undefined;
});
protected readonly relatedBooks = computed(() =>
this.related.hasValue() ? this.related.value().items.filter((b) => b.isbn !== this.isbn()) : [],
);
protected readonly text = signal('');
protected readonly saving = signal(false);
protected readonly saveError = signal<string | null>(null);
constructor() {
effect(() => console.log(`[book] ${this.book.status()} | [related] ${this.related.status()}`));
effect(() => console.log(`[reviews] ${this.reviews.status()}: ${this.reviews.value().length} reviews`));
}
httpResource takes a reactive function that returns a URL or a request object; when the signals the function reads (here isbn) change, the request is sent again, and a request still in flight is cancelled. The resource's state is in signals: value(), status(), error(), isLoading() and hasValue(). The two effects in the constructor only write the statuses to the console for this post. Opening the page:
0.1 s request: GET /categories
0.1 s request: GET /books/9780441172719
0.1 s request: GET /books/9780441172719/reviews
0.1 s console: [book] loading | [related] loading
0.1 s console: [reviews] loading: 0 reviews
0.4 s request: GET /books?category=science-fiction
0.4 s console: [book] resolved | [related] loading
0.4 s console: [reviews] resolved: 2 reviews
0.7 s console: [book] resolved | [related] resolved
--- user adds a review
1.7 s request: POST /books/9780441172719/reviews
1.7 s console: [reviews] local: 3 reviews
screen right after click: 3 reviews
2.0 s request: GET /books/9780441172719/reviews
2.0 s console: [reviews] reloading: 3 reviews
2.3 s console: [reviews] resolved: 3 reviews
screen: Ada (5/5): Still the best world-building I know. | Alan (4/5): Slow start, great second half. | Ada (5/5): Read it twice.
error : -
The first nine lines of this output are the reading side. The book and the reviews were requested in parallel, and both started in loading and moved to resolved. status() has six values: idle (no valid request), loading (the params changed and a value is loading), reloading (reload() was called with the same params and the old value stays), resolved, error and local (the value was written by hand with set/update). The template turns these values into screens with an @if chain:
@if (book.hasValue()) {
<h1>{{ book.value().title }}</h1>
<p class="book">{{ book.value().author }} - {{ book.value().price }} TRY</p>
} @else if (book.error()) {
<p class="book-error">Could not load this book.</p>
} @else {
<p>Loading...</p>
}
The order matters: hasValue() first, then error(). According to the Angular documentation, reading value() while the resource is in the error status throws; hasValue() separates both that case and the case where the value has not arrived yet, and in TypeScript it works as a type guard that strips undefined from the type. We will see the price of breaking this rule, with a real error message, in "Common Pitfalls".
The reviews' defaultValue: [] option makes value() return an empty array instead of undefined while the list has not arrived; the template needs no extra check. That suits a list where the difference between "loading" and "no reviews yet" does not matter. It does not suit the book itself: there, "loading" and "no such book" are different screens.
chain(): Resources That Depend on Each Other
The "More in this category" list depends on the book's category, which is only known once the book has arrived. The related resource's function receives a chain parameter: chain(this.book) returns the book resource's value if it is resolved or local; if the book is still loading it moves related to loading, to error if it is in error, to idle if it is idle, and does not run the rest of the function. The output above shows that order: related waited in loading until the book became resolved at 0.4 s, and only then did the /books?category=science-fiction request go out.
A small detail inside the function avoids a trap the Angular documentation also warns about: had I returned the category inside an object ({ category: undefined }), that would be a defined value, and the request would go out with category=undefined. Returning undefined when there is no value puts the resource in idle. Reading this.book.value() directly instead of chain would also work, but when the book failed, related would stay idle; chain carries the upstream resource's error downstream. If the dependency is only a synchronous derivation (for example calculating a discounted price from the book's price), a computed is enough, not a second resource.
After a Mutation: Optimistic Updates, Rolling Back and reload()
The Angular documentation advises against using httpResource for mutations such as POST or PUT; a resource is for reading. Adding a review is therefore done directly with HttpClient, and it uses three abilities of the resource: update to put the review in the list at once (an optimistic update), set to return to the old list on error, and reload() to refresh the list from the server after success.
The second half of the output above is the successful scenario. The moment the button is pressed, the status is local with 3 reviews: the user sees their review without waiting. The POST came back 0.3 seconds later, and reload() sent a new GET; meanwhile the status was reloading and the old value (3 reviews) stayed on screen, then resolved. The reason for reload() is that the optimistic review is a temporary copy added with id: -1; the real state of the list (the real id, fields added by the server, reviews others added in the meantime) is on the server. Now the scenario from "Predict First", with the mock API started so that it answers the first POST with 503:
0.1 s request: GET /categories
0.1 s request: GET /books/9780441172719
0.1 s request: GET /books/9780441172719/reviews
0.1 s console: [book] loading | [related] loading
0.1 s console: [reviews] loading: 0 reviews
0.4 s request: GET /books?category=science-fiction
0.4 s console: [book] resolved | [related] loading
0.4 s console: [reviews] resolved: 2 reviews
0.7 s console: [book] resolved | [related] resolved
--- user adds a review
1.6 s request: POST /books/9780441172719/reviews
1.6 s console: [reviews] local: 3 reviews
screen right after click: 3 reviews
1.9 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
1.9 s console: [reviews] local: 2 reviews
screen: Ada (5/5): Still the best world-building I know. | Alan (4/5): Slow start, great second half.
error : Could not save your review. Please try again.
The review again appeared at once (local: 3 reviews); when the 503 arrived, the list went back to its old state (local: 2 reviews) and the user saw an error message. Note that the status is local, not error: the resource's own request never failed, our POST did; and since the rollback is a set, the status is local. That is why the error message lives not in the resource but in the component's own saveError signal.
An optimistic update is not the right choice for every mutation. For operations like a review, which are easy to undo and unlikely to fail, it makes the app feel faster. For operations such as placing an order or paying, showing "done" on screen and then taking it back misleads the user; there, locking the button and waiting for the server's answer is right.
Fetching the Same Data Twice: A Shared Resource
We added a category menu to the header; the catalog page's filter links use the same list. In my first version, both components created their own httpResource:
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { apiUrl } from '../api';
import type { Category } from '../models';
@Component({
selector: 'app-category-menu',
imports: [RouterLink],
template: `
<nav class="category-menu">
@for (c of categories.value(); track c.slug) {
<a routerLink="/" [queryParams]="{ category: c.slug }">{{ c.name }}</a>
}
</nav>
`,
})
export class CategoryMenu {
// each component fetches its own copy
protected readonly categories = httpResource<Category[]>(() => `${apiUrl}/categories`, {
defaultValue: [],
});
}
The catalog page had the same line. Playwright opened the catalog, went to a book's details and came back; the requests that went to the API:
GET /api/categories
GET /api/books?page=1
GET /api/categories
GET /api/books/9780441172719
GET /api/books/9780441172719/reviews
GET /api/books?category=science-fiction
GET /api/books?page=1
GET /api/categories
=> 3 of 8 requests went to /api/categories
Three requests for a list that does not change: one from the menu, one from the catalog and one from the catalog component created again on the way back. The resources know nothing about each other; Angular has no global cache layer that merges them. The fix is to give server state an owner as well, like client state: keep the list as a single resource in a service at the root.
import { httpResource } from '@angular/common/http';
import { Service } from '@angular/core';
import { apiUrl } from '../api';
import type { Category } from '../models';
// Server state shared by every component that shows categories: one request, one copy
@Service()
export class Categories {
readonly #list = httpResource<Category[]>(() => `${apiUrl}/categories`, { defaultValue: [] });
readonly list = this.#list.asReadonly();
}
The menu and the catalog read the same resource with inject(Categories); what is exposed is a Resource obtained with asReadonly(), so a component cannot break the list with set. The same scenario:
GET /api/categories
GET /api/books?page=1
GET /api/books/9780441172719
GET /api/books/9780441172719/reviews
GET /api/books?category=science-fiction
GET /api/books?page=1
=> 1 of 6 requests went to /api/categories
One request. The price is that the list is now loaded once for the lifetime of the app and never refreshed; acceptable for categories, not for stock. A resource shared at the root only avoids becoming the first post's "global copy" problem once a refresh rule is defined too: calling reload() on certain events, or fetching again after a staleness period. If you have to write that rule by hand for every list, a tool such as TanStack Query, mentioned below, is a serious alternative.
NgRx 22's Resource Extensions
The catalog list is paged, and when the page changes the resource moves to loading. By Angular's definition, value() is undefined in loading; so the moment the user presses "Next", the list disappears and "Loading..." takes its place. Moving to page 2 of "classics" with a plain httpResource:
0.1 s console: [books] loading: -
0.4 s console: [books] resolved: Emma,Frankenstein
--- click Next
1.1 s console: [books] loading: -
screen 150 ms later: 0 books listed, "Loading..." shown
1.4 s console: [books] resolved: Middlemarch,Pride and Prejudice
screen after loading: "4 books, page 2 of 2" | Middlemarch, Pride and Prejudice
NgRx 22 introduced the @ngrx/signals/resource package, which adjusts behaviour like this without changing the resource. Its APIs are marked experimental in the NgRx documentation: they may change in later versions without breaking change announcements. extendResource takes a resource and returns it with the same type; withPreviousValueOnLoading() keeps the last value while loading:
// Server state: a copy of the API's data, fetched again when the category or page changes;
// the previous page stays on screen while the next one loads (NgRx resource extension)
protected readonly books = extendResource(
httpResource<BookPage>(() => {
const params: Record<string, string | number> = { page: this.page() };
const category = this.category();
if (category) params['category'] = category;
return { url: `${apiUrl}/books`, params };
}),
withPreviousValueOnLoading(),
);
0.1 s console: [books] loading: -
0.4 s console: [books] resolved: Emma,Frankenstein
--- click Next
1.1 s console: [books] loading: Emma,Frankenstein
screen 150 ms later: 2 books listed, "Updating..." shown
1.4 s console: [books] resolved: Middlemarch,Pride and Prejudice
screen after loading: "4 books, page 2 of 2" | Middlemarch, Pride and Prejudice
The status is still loading, but value() returns the previous page (Emma, Frankenstein); 150 ms later the screen still shows two books and a small "Updating..." line. The only change in the template is that line, added while isLoading() is true; computeds derived from this extension have a catch, covered in "Common Pitfalls". The other extensions: withValueOnLoading(x) returns a fixed value while loading, withPreviousValueOnError() the last successful value in the error status, withValueOnError(x) a fixed value in the error status; the last two also stop value() from throwing. With provideResourceExtensions(...) you can also make them the default at an injector level (application, route, component). Angular itself has resourceFromSnapshots, which can do the same job, but in Angular 22 it is experimental too.
In Production
If you use SSR, a resource's id option writes the value fetched on the server into TransferState and starts the resource in the browser directly as resolved; the same request does not go out a second time during hydration. That value is embedded in the page's HTML. That is why the Angular documentation explicitly advises against using id on resources that load data specific to the user who triggered the render, especially if the HTML can be cached. id is fine for the book list; not for "my orders" or the cart. Bookstore has no SSR in this series; this paragraph is based on the documentation and was not run.
If needs such as sharing between resources, request deduplication (one request when two components ask for the same URL at the same time), a staleness period, refetching on window focus and key-based invalidation after mutations grow, the Angular adapter of TanStack Query is worth considering instead of writing them by hand on top of Angular's resources. In November 2026 the package is still published as @tanstack/angular-query-experimental (the latest version on npm is 5.107.1); its name says its API is not yet considered stable. We don't use it in this series and did not run it; it comes back as an option in the post on choosing a tool.
Every resource means an HTTP request. Knowing how many resources a page has and which ones wait for each other (chain) also determines how long the page takes to fill. On the book details page the book and the reviews are parallel, while related books depend on the book; so the last part of the page arrives at the end of two requests made one after the other. The waterfall view in the Network tab shows that chain directly.
Common Pitfalls
Reading value() in the error status
Imagine you shortened the template and wrote the heading as {{ book.value()?.title ?? 'Loading...' }}. The mock API was started so that it answers the first book request with 503:
0.1 s request: GET /categories
0.1 s request: GET /books/9780441172719
0.1 s request: GET /books/9780441172719/reviews
0.4 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
0.4 s console: ERROR ResourceValueError: Resource is currently in an error state (see Error.cause for details): Http failure response for http://localhost:5280/api/books/9780441172719: 503 Service Unavailable
screen h1: Loading... | error: - | reviews listed: 0
Optional chaining (?.) did not help, because the problem is not undefined: value() itself threw ResourceValueError. The heading stayed "Loading...", and together with the error the rest of the template could not be rendered: not even the two reviews the server returned successfully are on screen. The real page, guarded with hasValue() and error(), in the same scenario:
0.1 s request: GET /categories
0.1 s request: GET /books/9780441172719
0.1 s request: GET /books/9780441172719/reviews
0.4 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
screen h1: - | error: Could not load this book. | reviews listed: 2
Deriving a computed from an extended resource
The "4 books, page 2 of 2" summary in the catalog is a computed. In its first version it read only hasValue() and value(). The same "Next" scenario after the resource was wrapped with withPreviousValueOnLoading():
0.1 s console: [books] loading: -
0.4 s console: [books] resolved: Emma,Frankenstein
--- click Next
1.1 s console: [books] loading: Emma,Frankenstein
screen 150 ms later: 2 books listed, "Updating..." shown
1.4 s console: [books] resolved: Middlemarch,Pride and Prejudice
screen after loading: "4 books, page 1 of 2" | Middlemarch, Pride and Prejudice
The list shows page 2, the summary still says "page 1 of 2", and it stays that way. NgRx 22.0.1's source code shows why: the extension replaces value with a Proxy and, while the resource is loading, returns the stored value without calling the real signal at all; it also reads isLoading with untracked. A computed evaluated during loading is thus left with no tracked signal and is never evaluated again. The template is not affected, because it reads isLoading() separately. The fix is to read a tracked signal first inside the computed:
// Derived state: computed from other state, never stored
protected readonly summary = computed(() => {
// status() is read first on purpose: while loading, the extended value() is not tracked
if (this.books.status() === 'error' || !this.books.hasValue()) return '';
const { total, page, pageSize } = this.books.value();
return `${total} books, page ${page} of ${Math.ceil(total / pageSize)}`;
});
protected readonly hasNextPage = computed(() => {
if (this.books.status() === 'error' || !this.books.hasValue()) return false;
const { total, page, pageSize } = this.books.value();
return page * pageSize < total;
});
This is how an experimental API behaves today; it may be fixed in later versions. As a rule, verify every computed derived from an extended resource by running it through a loading transition at least once.
Copying server state into a store and trying to keep it in sync
Writing a resource's value into another signal or a store with an effect and saying "the rest of the app should read from there" brings back the stale copy problem from the first post; on top of that, the loading and error information is lost along the way. If you need to share, share the resource itself, as in the Categories service.
Using httpResource for mutations
A resource sends its request again by itself when its params change and cancels the old request. Putting a POST into that model means an order or a review being sent again, or cancelled midway, when a signal changes. Read with a resource, write with HttpClient.
Forgetting to roll back an optimistic update
Without the set(before) in the catch block, the failed review stays in the list and the user thinks it was saved. To roll back, you have to keep "the previous state" right before the optimistic update; fetching again from the server afterwards is also an option, but in between the user sees the wrong list.
A Small Experiment
★☆☆ Add a "Refresh reviews" button to the book details page and wire it to reviews.reload(). Which values does the status go through when you press it? Does the list stay on screen?
★★☆ Add a refresh() method to the Categories service and write it so that it calls reload() only if the menu was loaded more than 10 minutes ago. To keep time testable, where should the current time come from?
★★★ Add an optimistic stock decrease to the add-to-cart button: when the user presses "Add to cart", the stock in the catalog should drop by one, and be rolled back if the server does not have enough stock (add a POST /api/cart/reserve endpoint to the mock API). Since the catalog list is wrapped with extendResource, do update and set still work?
The catalog's page number is still a linkedSignal in memory: a reload takes it back to 1, and it is lost when the link is shared. In the next post we move the catalog filters and the page to the URL: binding query params to inputs with withComponentInputBinding(), Angular 22's queryParams and unmatchedInputBehavior options, queryParamsHandling: 'merge', replaceUrl so the search box does not litter the history, and validating the strings that come from the URL.