Well-designed services implement consistent patterns for the three most common concerns: state management (what data do components bind to?), error handling (what happens when an API call fails?), and response caching (how do we avoid redundant API calls?). The Signal-based state pattern keeps components reactive and clean. The shareReplay(1) operator prevents duplicate HTTP requests when multiple components need the same data simultaneously. Optimistic updates make the UI feel instant while the API call completes in the background.
Complete PostsService with All Patterns
@Injectable({ providedIn: 'root' })
export class PostsService {
private http = inject(HttpClient);
private baseUrl = inject(API_BASE_URL);
// ── Signal-based state ─────────────────────────────────────────────────
private _posts = signal<PostSummaryDto[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
private _page = signal(1);
private _total = signal(0);
readonly posts = this._posts.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly total = this._total.asReadonly();
readonly hasMore = computed(() => this._posts().length < this._total());
// ── Cached Observable — shareReplay avoids duplicate HTTP calls ────────
// Multiple components subscribing to this within the same tick
// share one HTTP request
private _categoriesCache$?: Observable<CategoryDto[]>;
getCategories(): Observable<CategoryDto[]> {
if (!this._categoriesCache$) {
this._categoriesCache$ = this.http
.get<CategoryDto[]>(`${this.baseUrl}/api/categories`)
.pipe(
shareReplay(1), // replay last value to new subscribers
catchError(err => {
console.error('Failed to load categories', err);
return of([]); // return empty array on error
}),
);
}
return this._categoriesCache$;
}
// ── Load posts with error handling ────────────────────────────────────
loadPublished(page = 1, size = 10): void {
this._loading.set(true);
this._error.set(null);
this.http.get<PagedResult<PostSummaryDto>>(
`${this.baseUrl}/api/posts`,
{ params: { page, size } }
).pipe(
catchError(err => {
const message = err.status === 0
? 'Network error. Check your connection.'
: `Error ${err.status}: ${err.error?.title ?? 'Unknown error'}`;
return throwError(() => new Error(message));
}),
).subscribe({
next: result => {
this._posts.set(result.items);
this._total.set(result.total);
this._page.set(page);
this._loading.set(false);
},
error: err => {
this._error.set(err.message);
this._loading.set(false);
},
});
}
// ── Optimistic delete — update UI before API confirms ─────────────────
async deletePost(id: number): Promise<void> {
const previous = this._posts(); // save previous state
this._posts.update(posts => posts.filter(p => p.id !== id)); // instant UI update
try {
await firstValueFrom(
this.http.delete(`${this.baseUrl}/api/posts/${id}`)
);
this._total.update(n => n - 1);
} catch (err) {
this._posts.set(previous); // rollback on failure
this._error.set('Failed to delete post. Please try again.');
}
}
// ── Service-to-service communication via inject ────────────────────────
private authService = inject(AuthService);
readonly canCreate = computed(() => this.authService.isLoggedIn());
}
shareReplay(1) causes the Observable to multicast to all current and future subscribers, replaying the last emitted value to new subscribers. Without it, each subscriber triggers a new HTTP request. With it, the first subscriber triggers the HTTP call; subsequent subscribers immediately receive the cached response without a network request. Set shareReplay(1) (not shareReplay() which defaults to an unlimited buffer) to cache exactly the last response.catchError in an RxJS pipe returns an Observable — if you return throwError(() => new Error(message)), the error reaches the subscribe error handler. If you return of(fallbackValue), the error is suppressed and the fallback value is emitted to the next handler. Choose based on whether you want to handle the error at the subscribe level (use throwError) or suppress it with a default (use of()). Mixing these patterns inconsistently makes error flow hard to trace.Common Mistakes
Mistake 1 — No catchError on HTTP observables (unhandled error crashes the subscription)
❌ Wrong — no error handling; 500 from API terminates the subscription permanently; further interactions silently fail.
✅ Correct — always add catchError to handle HTTP errors and recover the stream gracefully.
Mistake 2 — Missing rollback in optimistic updates (UI shows deleted item as gone even though API failed)
❌ Wrong — optimistic delete applied but no rollback on API failure; the post disappears from UI permanently.
✅ Correct — save previous state before optimistic update; restore it in the catch block on API failure.