TanStack Query for Angular provides signals-based primitives for fetching, caching, and updating asynchronous data in Angular applications. It leverages Angular’s modern signals API and supports zoneless mode.
This package is currently experimental and requires Angular 16+.
Installation
npm install @tanstack/angular-query-experimental
# or
pnpm add @tanstack/angular-query-experimental
# or
yarn add @tanstack/angular-query-experimental
Setup
Provide TanStack Query in your application:
import { provideAngularQuery, QueryClient } from '@tanstack/angular-query-experimental'
import { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [
provideAngularQuery(new QueryClient()),
],
}
With Custom Configuration
import { provideAngularQuery, QueryClient } from '@tanstack/angular-query-experimental'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
})
export const appConfig: ApplicationConfig = {
providers: [
provideAngularQuery(queryClient),
],
}
Core Inject Functions
injectQuery
Fetch and cache data with the injectQuery function:
import { Component, signal } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-todos',
template: `
@if (query.isLoading()) {
<div>Loading...</div>
} @else if (query.error()) {
<div>Error: {{ query.error().message }}</div>
} @else {
<ul>
@for (todo of query.data(); track todo.id) {
<li>{{ todo.title }}</li>
}
</ul>
}
`,
})
export class TodosComponent {
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
}))
}
injectQuery returns a signal-based result. Access properties like data(), isLoading(), and error() as signals.
Reactive Queries with Signals
Angular Query automatically tracks signal dependencies:
import { Component, signal } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-todo-detail',
template: `
<button (click)="todoId.set(todoId() + 1)">Next Todo</button>
@if (query.data(); as todo) {
<h1>{{ todo.title }}</h1>
}
`,
})
export class TodoDetailComponent {
todoId = signal(1)
query = injectQuery(() => ({
queryKey: ['todo', this.todoId()],
queryFn: async () => {
const res = await fetch(`/api/todos/${this.todoId()}`)
return res.json()
},
}))
}
The function passed to injectQuery runs in a reactive context. When signals change, the query automatically updates.
injectMutation
Perform side effects with mutations:
import { Component } from '@angular/core'
import { injectMutation, injectQueryClient } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-add-todo',
template: `
<button
(click)="addTodo()"
[disabled]="mutation.isPending()"
>
{{ mutation.isPending() ? 'Adding...' : 'Add Todo' }}
</button>
`,
})
export class AddTodoComponent {
queryClient = injectQueryClient()
mutation = injectMutation(() => ({
mutationFn: async (newTodo: { title: string }) => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
})
return res.json()
},
onSuccess: () => {
this.queryClient.invalidateQueries({ queryKey: ['todos'] })
},
}))
addTodo() {
this.mutation.mutate({ title: 'New Todo' })
}
}
injectInfiniteQuery
Implement infinite scrolling:
import { Component } from '@angular/core'
import { injectInfiniteQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-posts',
template: `
@for (page of query.data()?.pages; track $index) {
@for (post of page.posts; track post.id) {
<div>{{ post.title }}</div>
}
}
<button
(click)="query.fetchNextPage()"
[disabled]="!query.hasNextPage() || query.isFetchingNextPage()"
>
{{ query.isFetchingNextPage() ? 'Loading...' : 'Load More' }}
</button>
`,
})
export class PostsComponent {
query = injectInfiniteQuery(() => ({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?page=${pageParam}`)
return res.json()
},
getNextPageParam: (lastPage: any) => lastPage.nextCursor,
initialPageParam: 0,
}))
}
Signals Integration
Signal-Based Results
All query results are signals:
import { Component, computed } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-todo-stats',
template: `
<div>Total: {{ totalTodos() }}</div>
<div>Completed: {{ completedTodos() }}</div>
`,
})
export class TodoStatsComponent {
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
totalTodos = computed(() => this.query.data()?.length ?? 0)
completedTodos = computed(() =>
this.query.data()?.filter(t => t.completed).length ?? 0
)
}
Enabled Queries with Signals
import { Component, signal } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-conditional-query',
template: `
<label>
<input type="checkbox" [(ngModel)]="enabled" />
Enable query
</label>
@if (query.data(); as data) {
<div>{{ data.length }} results</div>
}
`,
})
export class ConditionalQueryComponent {
enabled = signal(false)
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
enabled: this.enabled(), // Reactive to signal changes
}))
}
Zoneless Support
Angular Query works seamlessly in zoneless mode:
import { ApplicationConfig } from '@angular/core'
import { provideExperimentalZonelessChangeDetection } from '@angular/core'
import { provideAngularQuery, QueryClient } from '@tanstack/angular-query-experimental'
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideAngularQuery(new QueryClient()),
],
}
Angular Query uses signals internally, making it fully compatible with Angular’s zoneless change detection.
Advanced Features
Query Options Factory
import { queryOptions } from '@tanstack/angular-query-experimental'
export const todoQueries = {
all: () => queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
}),
detail: (id: number) => queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
}),
}
// Usage
@Component({
template: `<div>{{ query.data()?.title }}</div>`,
})
export class TodoDetailComponent {
query = injectQuery(() => todoQueries.detail(1))
}
injectMutationState
Track all mutations globally:
import { Component } from '@angular/core'
import { injectMutationState } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-global-loading',
template: `
@if (pendingCount() > 0) {
<div>Saving {{ pendingCount() }} changes...</div>
}
`,
})
export class GlobalLoadingComponent {
mutations = injectMutationState(() => ({
filters: { status: 'pending' },
}))
pendingCount = computed(() => this.mutations().length)
}
injectIsFetching
Show a global loading indicator:
import { Component } from '@angular/core'
import { injectIsFetching } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-loading-bar',
template: `
@if (isFetching()) {
<div class="loading-bar">Loading...</div>
}
`,
})
export class LoadingBarComponent {
isFetching = injectIsFetching()
}
Dependency Injection
Custom Injector
import { Component, Injector, inject } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-custom',
template: `...`,
})
export class CustomComponent {
injector = inject(Injector)
query = injectQuery(
() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}),
{ injector: this.injector }
)
}
Outside Injection Context
import { Component, effect } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
@Component({
selector: 'app-todos',
template: `...`,
})
export class TodosComponent {
query = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos }))
constructor() {
effect(() => {
if (this.query.isSuccess()) {
console.log('Query succeeded:', this.query.data())
}
})
}
}
TypeScript
Full TypeScript support with type inference:
import { Component } from '@angular/core'
import { injectQuery } from '@tanstack/angular-query-experimental'
interface Todo {
id: number
title: string
completed: boolean
}
@Component({
selector: 'app-todos',
template: `...`,
})
export class TodosComponent {
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos')
return res.json()
},
}))
// query.data() is typed as Signal<Todo[] | undefined>
}
SSR Support
For Angular Universal (Server-Side Rendering):
import { ApplicationConfig } from '@angular/core'
import { provideClientHydration } from '@angular/platform-browser'
import { provideAngularQuery, QueryClient } from '@tanstack/angular-query-experimental'
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(),
provideAngularQuery(new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
},
})),
],
}
Pending Tasks Integration
Angular Query automatically integrates with Angular’s pending tasks for SSR:
// Queries automatically mark themselves as pending during fetching
// This ensures SSR waits for queries to complete before rendering
const query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
// Automatically tracked as a pending task in SSR
}))
Install the Angular Query DevTools:
npm install @tanstack/angular-query-experimental
Setup:
import { ApplicationConfig } from '@angular/core'
import {
provideAngularQuery,
QueryClient,
withDevtools
} from '@tanstack/angular-query-experimental/devtools'
export const appConfig: ApplicationConfig = {
providers: [
provideAngularQuery(
new QueryClient(),
withDevtools()
),
],
}
Angular-Specific Patterns
Services Integration
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'
@Injectable({ providedIn: 'root' })
export class TodoService {
http = inject(HttpClient)
getTodos() {
return injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => lastValueFrom(this.http.get<Todo[]>('/api/todos')),
}))
}
}
// Usage in component
@Component({
selector: 'app-todos',
template: `...`,
})
export class TodosComponent {
todoService = inject(TodoService)
query = this.todoService.getTodos()
}
RxJS Integration
import { Component, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'
@Component({
selector: 'app-todos',
template: `...`,
})
export class TodosComponent {
http = inject(HttpClient)
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => lastValueFrom(this.http.get('/api/todos')),
}))
}
Use lastValueFrom to convert RxJS observables to promises for use with TanStack Query.
Migration Tips
From Angular HTTP Client
TanStack Query
HttpClient
import { injectQuery } from '@tanstack/angular-query-experimental'
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
}))
import { HttpClient, inject } from '@angular/core'
http = inject(HttpClient)
todos$ = this.http.get('/api/todos')
Signal Equality
import { injectQuery } from '@tanstack/angular-query-experimental'
import { computed } from '@angular/core'
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
// Computed signals automatically use reference equality
completedCount = computed(() =>
this.query.data()?.filter(t => t.completed).length ?? 0
)
Structural Sharing
Angular Query uses structural sharing by default to minimize signal updates:
query = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
// Structural sharing enabled by default
// Only changed parts of data trigger signal updates
}))