Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Optimistic Updates Example for Angular #8439

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Node.js",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22"
}
6 changes: 6 additions & 0 deletions examples/angular/optimistic-updates/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {}

module.exports = config
6 changes: 6 additions & 0 deletions examples/angular/optimistic-updates/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# TanStack Query Angular optimistic-updates example

To run this example:

- `npm install` or `yarn` or `pnpm i` or `bun i`
- `npm run start` or `yarn start` or `pnpm start` or `bun start`
104 changes: 104 additions & 0 deletions examples/angular/optimistic-updates/angular.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "pnpm",
"analytics": false,
"cache": {
"enabled": false
}
},
"newProjectRoot": "projects",
"projects": {
"optimistic-updates": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"inlineTemplate": true,
"inlineStyle": true,
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/optimistic-updates",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": [],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "optimistic-updates:build:production"
},
"development": {
"buildTarget": "optimistic-updates:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "optimistic-updates:build"
}
}
}
}
}
}
29 changes: 29 additions & 0 deletions examples/angular/optimistic-updates/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@tanstack/query-example-angular-optimistic-updates",
"type": "module",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development"
},
"private": true,
"dependencies": {
"@angular/common": "^19.1.0-next.0",
"@angular/compiler": "^19.1.0-next.0",
"@angular/core": "^19.1.0-next.0",
"@angular/forms": "19.1.0-next.0",
"@angular/platform-browser": "^19.1.0-next.0",
"@angular/platform-browser-dynamic": "^19.1.0-next.0",
"@tanstack/angular-query-experimental": "^5.62.7",
"rxjs": "^7.8.1",
"tslib": "^2.6.3",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular/build": "^19.0.2",
"@angular/cli": "^19.0.2",
"@angular/compiler-cli": "^19.1.0-next.0",
"typescript": "5.7.2"
}
}
11 changes: 11 additions & 0 deletions examples/angular/optimistic-updates/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { OptimisticUpdatesComponent } from './components/optimistic-updates.component'

@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-root',
standalone: true,
template: `<optimistic-updates />`,
imports: [OptimisticUpdatesComponent],
})
export class AppComponent {}
28 changes: 28 additions & 0 deletions examples/angular/optimistic-updates/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
provideHttpClient,
withFetch,
withInterceptors,
} from '@angular/common/http'
import {
QueryClient,
provideTanStackQuery,
withDevtools,
} from '@tanstack/angular-query-experimental'
import { mockInterceptor } from './interceptor/mock-api.interceptor'
import type { ApplicationConfig } from '@angular/core'

export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch(), withInterceptors([mockInterceptor])),
provideTanStackQuery(
new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
}),
withDevtools(),
),
],
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Component, inject } from '@angular/core'
import {
injectMutation,
injectQuery,
} from '@tanstack/angular-query-experimental'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { DatePipe } from '@angular/common'
import { TasksService } from '../services/tasks.service'

@Component({
selector: 'optimistic-updates',
imports: [FormsModule, DatePipe],
template: `
<p>
In this example, new items can be created using a mutation. The new item
will be optimistically added to the list in hopes that the server accepts
the item. If it does, the list is refetched with the true items from the
list. Every now and then, the mutation may fail though. When that happens,
the previous list of items is restored and the list is again refetched
from the server.
</p>

<hr />
@if (tasks.isLoading()) {
<p>Loading...</p>
}

<div class="container">
<label>
<input type="checkbox" [(ngModel)]="failMutation" />
Fail Mutation
</label>

<div class="input-container">
<input type="text" [(ngModel)]="newItem" placeholder="Enter text" />
<button (click)="addItem()">Create</button>
<ul>
@for (task of tasks.data(); track task) {
<li>{{ task }}</li>
}
</ul>

<div>
Updated At: {{ tasks.dataUpdatedAt() | date: 'MMMM d, h:mm:ss a ' }}
</div>
</div>
@if (!tasks.isLoading() && tasks.isFetching()) {
<p>Fetching in background</p>
}
</div>
`,
styles: ``,
})
export class OptimisticUpdatesComponent {
#tasksService = inject(TasksService)

tasks = (() => injectQuery(() => this.#tasksService.allTasks()))()
clearMutation = injectMutation(() => this.#tasksService.addTask())
addMutation = injectMutation(() => this.#tasksService.addTask())

newItem = ''
failMutation = false

addItem() {
if (!this.newItem) return

this.addMutation.mutate({
task: this.newItem,
failMutation: this.failMutation,
})
this.newItem = ''
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* MockApiInterceptor is used to simulate API responses for `/api/tasks` endpoints.
* It handles the following operations:
* - GET: Fetches all tasks from sessionStorage.
* - POST: Adds a new task to sessionStorage.
* Simulated responses include a delay to mimic network latency.
*/
import { HttpResponse } from '@angular/common/http'
import { delay, of } from 'rxjs'
import type {
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http'
import type { Observable } from 'rxjs'

export const mockInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<any>> => {
const respondWith = (status: number, body: any) =>
of(new HttpResponse({ status, body })).pipe(delay(500))
if (req.url === '/api/tasks') {
switch (req.method) {
case 'GET':
return respondWith(
200,
JSON.parse(
sessionStorage.getItem('optimistic-updates-tasks') || '[]',
),
)
case 'POST':
const tasks = JSON.parse(
sessionStorage.getItem('optimistic-updates-tasks') || '[]',
)
tasks.push(req.body)
sessionStorage.setItem(
'optimistic-updates-tasks',
JSON.stringify(tasks),
)
return respondWith(201, {
status: 'success',
task: req.body,
})
}
}
if (req.url === '/api/tasks-wrong-url') {
return respondWith(500, {
status: 'error',
})
}

return next(req)
}
Loading
Loading