typescript
59 lines · 9 steps
Optimistic updates with Angular signals
A signal-based Angular store keeps a todo list reactive while updating the UI ahead of the server and rolling back on failure.
Explained by
highlit
1import { Injectable, computed, inject, signal } from '@angular/core';
2import { HttpClient } from '@angular/common/http';
3import { firstValueFrom } from 'rxjs';
4
5export interface Todo {
6 id: string;
7 title: string;
8 done: boolean;
9}
10
11@Injectable({ providedIn: 'root' })
12export class TodoStore {
13 private readonly http = inject(HttpClient);
14 private readonly todos = signal<Todo[]>([]);
15
16 readonly all = this.todos.asReadonly();
17 readonly pending = computed(() => this.todos().filter((t) => !t.done));
18 readonly completedCount = computed(() => this.todos().filter((t) => t.done).length);
19
20 async load(): Promise<void> {
21 const data = await firstValueFrom(this.http.get<Todo[]>('/api/todos'));
22 this.todos.set(data);
23 }
24
25 async toggle(id: string): Promise<void> {
26 const snapshot = this.todos();
27 const target = snapshot.find((t) => t.id === id);
28 if (!target) return;
29
30 const nextDone = !target.done;
31 this.todos.update((list) =>
32 list.map((t) => (t.id === id ? { ...t, done: nextDone } : t))
33 );
34
35 try {
36 await firstValueFrom(
37 this.http.patch<Todo>(`/api/todos/${id}`, { done: nextDone })
38 );
39 } catch {
40 this.todos.set(snapshot);
41 }
42 }
43
44 async add(title: string): Promise<void> {
45 const optimistic: Todo = { id: crypto.randomUUID(), title, done: false };
46 this.todos.update((list) => [...list, optimistic]);
47
48 try {
49 const saved = await firstValueFrom(
50 this.http.post<Todo>('/api/todos', { title })
51 );
52 this.todos.update((list) =>
53 list.map((t) => (t.id === optimistic.id ? saved : t))
54 );
55 } catch {
56 this.todos.update((list) => list.filter((t) => t.id !== optimistic.id));
57 }
58 }
59}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A writable signal as the single source of truth lets derived computed values stay in sync automatically.
- 2Optimistic updates feel instant by mutating local state first and reconciling with the server response afterward.
- 3Capturing a snapshot before mutating gives you a clean rollback path when a request fails.
Related explainers
rust
use axum::{ extract::{FromRef, FromRequestParts}, http::{header, request::Parts, StatusCode}, RequestPartsExt,
How a JWT extractor works in Axum
jwt
authentication
extractors
Intermediate
8 steps
typescript
import { InjectionToken, inject, Provider, isDevMode } from '@angular/core'; import { WINDOW } from './window.token'; export interface AnalyticsConfig {
Layered config with an Angular InjectionToken
dependency-injection
configuration
factory-provider
Intermediate
8 steps
typescript
import { useCallback, useRef, useState } from "react"; type UploadZoneProps = { accept?: string[];
A drag-and-drop file upload zone in React
drag-and-drop
file-validation
controlled-state
Intermediate
9 steps
rust
#[derive(Deserialize)] pub struct CreateArticle { title: String, body: String,
Building a create endpoint in Axum
extractors
json-deserialization
sqlx
Intermediate
7 steps
go
func UploadDocument(c *gin.Context) { fileHeader, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
Handling multipart uploads in Gin
multipart-upload
validation
error-handling
Intermediate
9 steps
typescript
import { useState, useEffect, useRef, useCallback } from "react"; interface Suggestion { id: string;
A debounced autocomplete hook in React
debounce
custom-hooks
abortcontroller
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/optimistic-updates-with-angular-signals-explained-typescript-e873/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.