Skip to main content
This guide will help you build your first Angular Query application. You’ll learn how to fetch data, handle loading states, and perform mutations.

What You’ll Build

We’ll create a simple blog post viewer with:
  • Fetching and displaying a list of posts
  • Loading and error states
  • Creating new posts with mutations
  • Automatic cache updates

Prerequisites

Make sure you’ve completed the installation steps:
  • Installed @tanstack/angular-query-experimental
  • Configured provideTanStackQuery in your app

Step 1: Create a Data Service

First, let’s create a service to fetch data. We’ll use Angular’s HttpClient:
1
Create the service
2
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { lastValueFrom } from 'rxjs'

export interface Post {
  id: number
  title: string
  body: string
  userId: number
}

@Injectable({
  providedIn: 'root'
})
export class PostsService {
  #http = inject(HttpClient)
  readonly #baseUrl = 'https://jsonplaceholder.typicode.com'

  async getPosts(): Promise<Post[]> {
    return lastValueFrom(
      this.#http.get<Post[]>(`${this.#baseUrl}/posts`)
    )
  }

  async getPost(id: number): Promise<Post> {
    return lastValueFrom(
      this.#http.get<Post>(`${this.#baseUrl}/posts/${id}`)
    )
  }

  async createPost(post: Omit<Post, 'id'>): Promise<Post> {
    return lastValueFrom(
      this.#http.post<Post>(`${this.#baseUrl}/posts`, post)
    )
  }
}
3
We’re using lastValueFrom to convert RxJS observables to promises, which Angular Query expects. You can also use observables directly with additional setup.
4
Add HttpClient provider
5
Ensure provideHttpClient is in your app config:
6
import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient } from '@angular/common/http'
import { 
  provideTanStackQuery,
  QueryClient 
} from '@tanstack/angular-query-experimental'
import { AppComponent } from './app/app.component'

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
    provideTanStackQuery(new QueryClient())
  ]
})

Step 2: Fetch and Display Posts

Now let’s create a component that fetches and displays posts:
components/posts-list.component.ts
import { Component, inject } from '@angular/core'
import { CommonModule } from '@angular/common'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { PostsService } from '../services/posts.service'

@Component({
  selector: 'app-posts-list',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="posts-container">
      <h1>Blog Posts</h1>
      
      @if (postsQuery.isPending()) {
        <div class="loading">
          <p>Loading posts...</p>
        </div>
      }
      
      @if (postsQuery.isError()) {
        <div class="error">
          <p>Error: {{ postsQuery.error()?.message }}</p>
          <button (click)="postsQuery.refetch()">Retry</button>
        </div>
      }
      
      @if (postsQuery.isSuccess()) {
        <div class="posts-list">
          @for (post of postsQuery.data(); track post.id) {
            <article class="post-card">
              <h2>{{ post.title }}</h2>
              <p>{{ post.body }}</p>
              <small>Post ID: {{ post.id }}</small>
            </article>
          }
        </div>
      }
      
      @if (postsQuery.isFetching()) {
        <p class="fetching-indicator">Updating...</p>
      }
    </div>
  `,
  styles: [`
    .posts-container {
      max-width: 800px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    .post-card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 1.5rem;
      margin-bottom: 1rem;
    }
    
    .post-card h2 {
      margin-top: 0;
      color: #333;
    }
    
    .error {
      color: red;
      padding: 1rem;
      border: 1px solid red;
      border-radius: 4px;
    }
    
    .loading {
      text-align: center;
      padding: 2rem;
    }
    
    .fetching-indicator {
      text-align: center;
      color: #666;
      font-style: italic;
    }
  `]
})
export class PostsListComponent {
  #postsService = inject(PostsService)
  
  postsQuery = injectQuery(() => ({
    queryKey: ['posts'],
    queryFn: () => this.#postsService.getPosts()
  }))
}

Understanding the Code

postsQuery = injectQuery(() => ({
  queryKey: ['posts'],
  queryFn: () => this.#postsService.getPosts()
}))
Key concepts:
  • queryKey: A unique identifier for this query. Used for caching and refetching.
  • queryFn: An async function that fetches the data.
  • injectQuery: Returns a signal-based object with query state and data.

Step 3: Add a Single Post View

Let’s create a component to view a single post with reactive parameters:
components/post-detail.component.ts
import { Component, inject, input, numberAttribute } from '@angular/core'
import { CommonModule } from '@angular/common'
import { injectQuery } from '@tanstack/angular-query-experimental'
import { PostsService } from '../services/posts.service'

@Component({
  selector: 'app-post-detail',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="post-detail">
      <button (click)="goBack()">← Back</button>
      
      @if (postQuery.isPending()) {
        <p>Loading post...</p>
      }
      
      @if (postQuery.isSuccess()) {
        <article>
          <h1>{{ postQuery.data()?.title }}</h1>
          <p>{{ postQuery.data()?.body }}</p>
          <small>Post ID: {{ postQuery.data()?.id }}</small>
        </article>
      }
      
      @if (postQuery.isError()) {
        <p class="error">Failed to load post</p>
      }
    </div>
  `,
  styles: [`
    .post-detail {
      max-width: 800px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    article {
      margin-top: 2rem;
    }
  `]
})
export class PostDetailComponent {
  #postsService = inject(PostsService)
  
  // Input signal from router or parent component
  postId = input.required({ transform: numberAttribute })
  
  // Query automatically refetches when postId changes
  postQuery = injectQuery(() => ({
    queryKey: ['posts', this.postId()],
    queryFn: () => this.#postsService.getPost(this.postId())
  }))
  
  goBack() {
    history.back()
  }
}
The query function runs in a reactive context. When postId() changes, Angular Query automatically detects the change and refetches with the new ID.

Step 4: Create Posts with Mutations

Now let’s add the ability to create new posts:
components/create-post.component.ts
import { Component, inject, signal } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { 
  injectMutation,
  injectQueryClient 
} from '@tanstack/angular-query-experimental'
import { PostsService, Post } from '../services/posts.service'

@Component({
  selector: 'app-create-post',
  standalone: true,
  imports: [CommonModule, FormsModule],
  template: `
    <div class="create-post">
      <h2>Create New Post</h2>
      
      <form (submit)="handleSubmit($event)">
        <div class="form-group">
          <label for="title">Title</label>
          <input
            id="title"
            [(ngModel)]="title"
            name="title"
            required
            placeholder="Enter post title"
          />
        </div>
        
        <div class="form-group">
          <label for="body">Body</label>
          <textarea
            id="body"
            [(ngModel)]="body"
            name="body"
            required
            rows="5"
            placeholder="Enter post content"
          ></textarea>
        </div>
        
        <button 
          type="submit" 
          [disabled]="createPostMutation.isPending()"
        >
          {{ createPostMutation.isPending() ? 'Creating...' : 'Create Post' }}
        </button>
      </form>
      
      @if (createPostMutation.isError()) {
        <div class="error">
          <p>Error: {{ createPostMutation.error()?.message }}</p>
        </div>
      }
      
      @if (createPostMutation.isSuccess()) {
        <div class="success">
          <p>Post created successfully!</p>
          <button (click)="resetForm()">Create Another</button>
        </div>
      }
    </div>
  `,
  styles: [`
    .create-post {
      max-width: 600px;
      margin: 0 auto;
      padding: 2rem;
    }
    
    .form-group {
      margin-bottom: 1rem;
    }
    
    label {
      display: block;
      margin-bottom: 0.5rem;
      font-weight: bold;
    }
    
    input, textarea {
      width: 100%;
      padding: 0.5rem;
      border: 1px solid #ddd;
      border-radius: 4px;
    }
    
    button {
      background: #007bff;
      color: white;
      padding: 0.75rem 1.5rem;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    
    button:disabled {
      background: #ccc;
      cursor: not-allowed;
    }
    
    .success {
      color: green;
      padding: 1rem;
      border: 1px solid green;
      border-radius: 4px;
      margin-top: 1rem;
    }
  `]
})
export class CreatePostComponent {
  #postsService = inject(PostsService)
  #queryClient = injectQueryClient()
  
  title = signal('')
  body = signal('')
  
  createPostMutation = injectMutation(() => ({
    mutationFn: (newPost: Omit<Post, 'id'>) => 
      this.#postsService.createPost(newPost),
    onSuccess: () => {
      // Invalidate posts query to refetch the list
      this.#queryClient.invalidateQueries({ queryKey: ['posts'] })
      // Reset form
      this.resetForm()
    }
  }))
  
  handleSubmit(event: Event) {
    event.preventDefault()
    
    this.createPostMutation.mutate({
      title: this.title(),
      body: this.body(),
      userId: 1
    })
  }
  
  resetForm() {
    this.title.set('')
    this.body.set('')
    this.createPostMutation.reset()
  }
}

Understanding Mutations

createPostMutation = injectMutation(() => ({
  mutationFn: (newPost) => this.#postsService.createPost(newPost),
  onSuccess: () => {
    this.#queryClient.invalidateQueries({ queryKey: ['posts'] })
  }
}))
  • mutationFn: The function that performs the mutation
  • onSuccess: Callback called when mutation succeeds
  • invalidateQueries: Marks queries as stale to trigger refetch

Step 5: Put It All Together

Create a main app component that uses all the pieces:
app.component.ts
import { Component, signal } from '@angular/core'
import { PostsListComponent } from './components/posts-list.component'
import { CreatePostComponent } from './components/create-post.component'

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PostsListComponent, CreatePostComponent],
  template: `
    <div class="app">
      <header>
        <h1>Angular Query Blog</h1>
        <button (click)="toggleView()">
          {{ showCreateForm() ? 'View Posts' : 'Create Post' }}
        </button>
      </header>
      
      <main>
        @if (showCreateForm()) {
          <app-create-post />
        } @else {
          <app-posts-list />
        }
      </main>
    </div>
  `,
  styles: [`
    .app {
      min-height: 100vh;
      background: #f5f5f5;
    }
    
    header {
      background: white;
      padding: 1rem 2rem;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    header h1 {
      margin: 0;
    }
  `]
})
export class AppComponent {
  showCreateForm = signal(false)
  
  toggleView() {
    this.showCreateForm.update(v => !v)
  }
}

Next Steps

Queries Guide

Learn advanced query patterns

Mutations Guide

Master mutations and optimistic updates

DevTools

Debug your queries visually

API Reference

Explore the complete API

Common Patterns

Dependent Queries

Fetch data that depends on other data:
user = injectQuery(() => ({
  queryKey: ['user'],
  queryFn: () => fetchUser()
}))

userPosts = injectQuery(() => ({
  queryKey: ['posts', 'user', this.user.data()?.id],
  queryFn: () => fetchUserPosts(this.user.data()!.id),
  enabled: !!this.user.data()?.id // Only run when we have a user ID
}))

Optimistic Updates

Update UI immediately before server responds:
updatePostMutation = injectMutation(() => ({
  mutationFn: (updatedPost: Post) => this.api.updatePost(updatedPost),
  onMutate: async (updatedPost) => {
    // Cancel outgoing refetches
    await this.queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] })
    
    // Snapshot previous value
    const previous = this.queryClient.getQueryData(['posts', updatedPost.id])
    
    // Optimistically update
    this.queryClient.setQueryData(['posts', updatedPost.id], updatedPost)
    
    return { previous }
  },
  onError: (err, variables, context) => {
    // Rollback on error
    this.queryClient.setQueryData(
      ['posts', variables.id],
      context?.previous
    )
  }
}))

Pagination

page = signal(1)

postsQuery = injectQuery(() => ({
  queryKey: ['posts', { page: this.page() }],
  queryFn: () => fetchPosts(this.page()),
  keepPreviousData: true // Keep showing old data while fetching new page
}))

nextPage() {
  this.page.update(p => p + 1)
}

previousPage() {
  this.page.update(p => Math.max(1, p - 1))
}

Build docs developers (and LLMs) love